15 Commits

Author SHA1 Message Date
BenClaw 4a4e076416 fix(action_cable): use Redis adapter in development too
- async adapter is in-process — works in Puma request cycle
  but fails from Rails console (no event loop to deliver messages)
- Redis is shared-state, works from any context (console, jobs, requests)
- Dev uses Redis DB 2, separate channel_prefix from production
2026-05-17 20:16:01 +02:00
BenClaw 02df03282e Revert "fix(broadcasting): broadcast on mark_helped! even when already false"
This reverts commit 4ad701c1a5.
2026-05-17 20:12:22 +02:00
BenClaw 4ad701c1a5 fix(broadcasting): broadcast on mark_helped! even when already false
- mark_helped! gated broadcast on if save, but save returns false
  when needs_help is already false (no dirty attributes in CouchDB)
- Same fix applied to remove_needs_payment!
- Broadcast is the important side effect — save is just persistence
2026-05-17 19:48:19 +02:00
BenClaw bdd1d248db debug(action_cable): add server-side broadcast logging
- Log channel remapping and data on every broadcast
- Log warnings when channel format is unknown
- Helps trace whether broadcasts reach ActionCable.server
2026-05-17 19:41:00 +02:00
BenClaw 7c69f0a0bc fix(action_cable): accept both /user/123 and /user_123 channel formats
- Benjamin standardized on /user_123 in mozo.rb (underscore, no slash)
- Old remap regex ^/user/(.+)$ didn't match /user_123
- Fix: ^/user[/_](.+)$ accepts both separators → user_123
2026-05-17 19:27:30 +02:00
bterkuile 11ba8e7434 channel naming convention change 2026-05-17 12:24:07 -05:00
bterkuile 383872b800 require the action_cable engine instead of just the root 2026-05-17 12:07:15 -05:00
BenClaw ee8861355b fix(action_cable): add missing ApplicationCable::Channel base class
- MozoChannel < ApplicationCable::Channel was failing with
  NameError: uninitialized constant ApplicationCable::Channel
- Standard Rails convention requires both connection.rb and channel.rb
2026-05-17 19:06:00 +02:00
BenClaw df04e99447 fix(action_cable): ensure logger is set for upgraded Rails app
- ActionCable::TaggedLoggerProxy crashes with NoMethodError when
  logger is nil (common in apps upgraded from older Rails)
- Add after_initialize hook to guarantee ActionCable.server.config.logger
  falls back to Rails.logger or
2026-05-17 18:49:13 +02:00
bterkuile 4bee13aae7 activate ActionCable messaging instead of Faye 2026-05-17 11:44:53 -05:00
bterkuile 12836dd14b Switch to Redis counter 2026-05-17 10:48:05 -05:00
bterkuile 5dbb6dbeae Explicitly require action_cable 2026-05-17 09:42:48 -05:00
BenClaw 3e4bcc80c8 feat(counter): add Redis counter adapter, replace DrbCounter
- Add Mozo::Counter::Redis with same get/set/incr/decr interface
- Add redis gem (~> 5.0) to Gemfile
- Update cable.yml to use Redis adapter in production (shared with counters)
- Document DrbCounter → Redis migration in broadcasting-migration.md
- Redis installed and running on vmi3300327
- Leave Faye as current broadcaster; both switches are one-line changes

DrbCounter problems solved:
  - In-memory → persistent (RDB + AOF)
  - Single-process DRb → multi-process safe Redis
  - Atomic INCR/DECR across Puma workers
  - One less custom process to manage
2026-05-17 16:42:09 +02:00
BenClaw a755d8a205 refactor(broadcasting): add Broadcastable to List + Order, remove monkey-patch
- include Broadcastable in app/models/list.rb
- include Broadcastable in app/models/order.rb
- Remove config/initializers/model_broadcast.rb (ApplicationController.new anti-pattern)
- Broadcasting now goes through Mozo.broadcast_* directly, not via controller hack
2026-05-17 16:36:28 +02:00
root 1f52448253 feat(broadcasting): add ActionCable adapter + fix model broadcast anti-pattern
- Add Mozo::Broadcaster::ActionCable as drop-in Faye replacement
- Fix model_broadcast.rb: delegate to Mozo directly instead of
  ApplicationController.new (memory-unsafe anti-pattern)
- Add Broadcastable concern for clean model-side broadcasting
- ActionCable config: async adapter, cable.yml, WebSocket endpoint
- MozoChannel with per-entity authorization (user/supplier/employee)
- Connection auth via auth_token (matches existing auth pattern)
- Mount /cable WebSocket in routes
- Add broadcasting-migration.md with Faye→ActionCable guide
2026-05-17 15:25:49 +02:00
21 changed files with 458 additions and 22 deletions
+1
View File
@@ -7,6 +7,7 @@ source 'https://rubygems.org'
gem 'rails', '~> 8.1.1'
#gem 'rails', '7.1.1'
gem 'rack-cors', require: 'rack/cors'
gem 'redis', '~> 5.0'
# Bundle edge Rails instead:
# gem 'rails', git: 'git://github.com/rails/rails.git'
+5
View File
@@ -480,6 +480,10 @@ GEM
erb
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.29.0)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
@@ -647,6 +651,7 @@ DEPENDENCIES
rack-cors
rails (~> 8.1.1)
rails-controller-testing
redis (~> 5.0)
rqrcode
rspec-its
rspec-rails
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
@@ -0,0 +1,36 @@
# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
# Authenticate via auth_token (same mechanism used in ApplicationController#authenticate_employee!)
# Clients should pass ?auth_token=TOKEN when connecting to the WebSocket.
identified_by :current_user, :current_entity_type
def connect
token = request.params[:auth_token].presence
reject_unauthorized_connection unless token
if (employee = Employee.find_by_authentication_token(token))
self.current_user = employee
self.current_entity_type = :employee
elsif (user = User.find_by_authentication_token(token))
self.current_user = user
self.current_entity_type = :user
elsif (supplier = Supplier.find_by_authentication_token(token))
self.current_user = supplier
self.current_entity_type = :supplier
else
reject_unauthorized_connection
end
end
# Allow subscribing to the entity's own channel
def subscribe_to_self
case current_entity_type
when :user then "user_#{current_user.id}"
when :supplier then "supplier_#{current_user.id}"
when :employee then "employee_#{current_user.id}"
end
end
end
end
+41
View File
@@ -0,0 +1,41 @@
# frozen_string_literal: true
# Base channel. Streams are set up dynamically by clients subscribing
# to their entity channel (user_123, supplier_456, etc.).
#
# The server broadcasts TO these channels via:
# ActionCable.server.broadcast("user_123", { event: "...", data: {...} })
#
# Clients connect and subscribe via:
# consumer.subscriptions.create({ channel: "MozoChannel", id: "user_123" })
#
class MozoChannel < ApplicationCable::Channel
def subscribed
stream_name = params[:id]
if authorized?(stream_name)
stream_from stream_name
else
reject
end
end
def unsubscribed
# cleanup
end
private
def authorized?(stream_name)
prefix, id = stream_name.to_s.split('_', 2)
case prefix
when 'user'
connection.current_entity_type == :user && connection.current_user.id.to_s == id
when 'supplier'
connection.current_entity_type == :supplier && connection.current_user.id.to_s == id
when 'employee'
connection.current_entity_type == :employee && connection.current_user.id.to_s == id
else
false
end
end
end
+29
View File
@@ -0,0 +1,29 @@
# frozen_string_literal: true
# Include this in any model that needs to broadcast events to users/suppliers.
#
# Replaces the old model_broadcast.rb initializer which monkey-patched
# SimplyStored::Couch and created ApplicationController.new per broadcast
# (memory-unsafe, no request context, to be removed once all callers migrate).
#
# Usage:
# class List < ApplicationRecord
# include Broadcastable
#
# def close!
# broadcast_user(user.id, 'list_closed', { id: id })
# broadcast_supplier(supplier_id, 'list_closed', { id: id })
# end
# end
#
module Broadcastable
extend ActiveSupport::Concern
def broadcast_supplier(sid, event, data = {})
Mozo.broadcast_supplier(sid, event, data)
end
def broadcast_user(uid, event, data = {})
Mozo.broadcast_user(uid, event, data)
end
end
+1
View File
@@ -1,6 +1,7 @@
class List
include SimplyStored::Couch
include ActiveModel::SerializerSupport
include Broadcastable
include List::JoinRequests
per_page_method :limit_value #kaminari
+1
View File
@@ -1,6 +1,7 @@
class Order
include SimplyStored::Couch
include ActiveModel::SerializerSupport
include Broadcastable
property :state, default: 'placed' # placed, active, delivered, cancelled, closed
+1
View File
@@ -5,6 +5,7 @@ require 'rails'
#require 'active_record/railtie'
require 'action_controller/railtie'
require 'action_mailer/railtie'
require 'action_cable/engine'
#require 'active_resource/railtie'
require 'rails/test_unit/railtie'
require 'sprockets/railtie'
+19
View File
@@ -0,0 +1,19 @@
# ActionCable configuration for real-time broadcasting.
#
# Development: async adapter (in-process, no external dependency).
# Test: test adapter.
# Production: Redis adapter — required for multi-worker deployments.
# Redis is also used for Mozo::Counter (replacing DrbCounter).
#
development:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/2" } %>
channel_prefix: mozo_backend_dev
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: mozo_backend
+2
View File
@@ -14,6 +14,8 @@ Mozo::Application.configure do
resource '*', headers: :any, methods: %i[get post put patch delete options]
end
end
#config.action_cable.allowed_request_origins = ['https://localhost:4201', 'https://localhost:4202']
config.lnd_credentials_path = '/mnt/ext1/.lnd/tls.cert'
config.lnd_macaroon_path = '/mnt/ext1/.lnd/data/chain/bitcoin/mainnet/admin.macaroon'
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
# Ensure ActionCable has a logger in all environments.
# In apps upgraded from older Rails versions, the logger chain
# may not propagate to ActionCable out of the box, causing:
# NoMethodError (undefined method 'info' for nil)
# in ActionCable::Connection::TaggedLoggerProxy#log
#
Rails.application.config.after_initialize do
ActionCable.server.config.logger ||= Rails.logger || ActiveSupport::Logger.new($stdout)
ActionCable.server.config.logger.level = Rails.logger&.level || Logger::INFO
end
-12
View File
@@ -1,12 +0,0 @@
#TODO: this is really ugly, can cause memory leaks and much more bad stuff. We need a new broadcaster....
require 'simply_stored/couch'
module ModelBroadcast
def broadcast_supplier(*args)
ApplicationController.new.send(:broadcast_supplier, *args)
end
def broadcast_user(*args)
ApplicationController.new.send(:broadcast_user, *args)
end
end
SimplyStored::Couch.send(:include, ModelBroadcast)
#SimplyStored::Couch.send(:extend, ModelBroadcast) # this should never happen!!!
+10 -8
View File
@@ -8,12 +8,14 @@ else
Mozo.user_url = 'https://user.mozo.bar'
end
Mozo.broadcaster = Mozo::Broadcaster::Faye.new
# Broadcaster: swap Faye ↔ ActionCable
# Mozo.broadcaster = Mozo::Broadcaster::Faye.new # current (HTTP POST to Faye)
# Mozo.broadcaster = Mozo::Broadcaster::ActionCable.new # new (in-process async)
#Mozo.broadcaster = Mozo::Broadcaster::Faye.new
Mozo.broadcaster = Mozo::Broadcaster::ActionCable.new # new (in-process async)
# use the connection from couchbase-structures/documents
# will be overwritten in the specs since flushing the real
# thing is difficult
# Mozo::Counter.connection = $cb unless Rails.env.test?
# Use the Drb counter
Mozo::Counter.connection = Mozo::DrbCounter.object unless Rails.env.test?
# Counter: swap DrbCounter ↔ Redis
# Mozo::Counter.connection = Mozo::DrbCounter.object # current (DRb in-memory)
# Mozo::Counter.connection = Mozo::Counter::Redis.new # new (persistent, multi-process)
#Mozo::Counter.connection = Mozo::DrbCounter.object unless Rails.env.test?
Mozo::Counter.connection = Mozo::Counter::Redis.new unless Rails.env.test? # new (persistent, multi-process)
+4
View File
@@ -1,5 +1,9 @@
ALLOWED_LOCALES = /nl|de|fr|en|es/
Mozo::Application.routes.draw do
# ActionCable WebSocket endpoint (replaces Faye at events.mozo.bar/faye)
# Clients connect via: wss://mozo.bar/cable?auth_token=TOKEN
mount ActionCable.server => '/cable'
match '/.well-known/*rest', to: 'errors#not_found', via: :all
match '/system/*rest', to: 'errors#not_found', via: :all
devise_for :users, controllers: {
+149
View File
@@ -0,0 +1,149 @@
# Broadcasting: Faye → ActionCable Migration Guide
## Current state
```
Model (SimplyStored::Couch)
→ ApplicationController.new.broadcast_user # ⚠️ anti-pattern
→ Mozo.broadcast_user
→ Mozo::Broadcaster::Faye.new.broadcast # HTTP POST to Faye
→ Faye server (Thin, port 9296)
→ WebSocket → browser clients
```
## Target state
```
Model (Broadcastable concern)
→ Mozo.broadcast_user
→ Mozo::Broadcaster::ActionCable.new.broadcast # in-process async
→ ActionCable (Rails built-in)
→ WebSocket → browser clients
```
## What this branch adds
| File | Purpose |
|------|---------|
| `lib/mozo/broadcaster/action_cable.rb` | Drop-in ActionCable broadcaster adapter |
| `config/cable.yml` | ActionCable configuration (async for single-server) |
| `app/channels/application_cable/connection.rb` | WebSocket auth via auth_token |
| `app/channels/mozo_channel.rb` | Channel authorization for user/supplier/employee |
| `app/models/concerns/broadcastable.rb` | Clean module for models (replaces old monkey-patch) |
| `config/routes.rb` | Mounts `/cable` WebSocket endpoint |
| `config/initializers/model_broadcast.rb` | Fixed: delegates to Mozo directly (no more `ApplicationController.new`) |
## How to switch
### 1. Server (one-line change)
In `config/initializers/mozo_settings.rb`, change:
```ruby
# Mozo.broadcaster = Mozo::Broadcaster::Faye.new # old
Mozo.broadcaster = Mozo::Broadcaster::ActionCable.new # new
```
### 2. Client (mozo-user / mozo-supplier)
**Old Faye client (conceptual):**
```js
var client = new Faye.Client('https://events.mozo.bar/faye');
client.subscribe('/user/123', function(msg) { ... });
```
**New ActionCable client:**
```js
// Using @rails/actioncable npm package
import { createConsumer } from "@rails/actioncable";
const consumer = createConsumer(
`wss://mozo.bar/cable?auth_token=${authToken}`
);
consumer.subscriptions.create(
{ channel: "MozoChannel", id: "user_123" },
{
received(data) {
// data = { event: "list_closed", data: { id: 42 } }
handleEvent(data.event, data.data);
}
}
);
```
### 3. Remove Faye
Once stable:
- Remove `gem 'faye'` from Gemfile
- Remove `faye/` directory
- Remove nginx `events.mozo.bar` vhost
- Stop the Faye Thin process
## Benefits
- **No extra process** — ActionCable runs inside Puma
- **Async** — `broadcast` is non-blocking
- **Simpler deploys** — one less service to manage
- **WebSocket native** — no long-polling fallback complexity
- **Rails auth** — cookies/sessions work automatically
---
# Counter: DrbCounter → Redis Migration
## Current state
```
Supplier::Counters (app/models/supplier/counters.rb)
→ Mozo::Counter.incr/decr/get/set
→ Mozo::Counter.connection (Mozo::DrbCounter.object)
→ DRb → druby://localhost:9022
→ InMemoryQCounter (separate Ruby process)
→ on startup: reloads counts from CouchDB
→ in-memory only (lost on restart)
```
## Problems
1. **In-memory only** — restart the DRb process = lose all counts until CouchDB reload
2. **Single-process** — DRb runs one Ruby process, single point of failure
3. **Separate process** — another thing to monitor, deploy, and restart
4. **Race conditions** — between Puma workers, increment/decrement is not atomic across the DRb boundary
5. **Custom code**`InMemoryQCounter` is 100 lines of hand-rolled counter logic
## Target state
```
Supplier::Counters
→ Mozo::Counter.incr/decr/get/set
→ Mozo::Counter.connection (Mozo::Counter::Redis.new)
→ Redis (localhost:6379)
→ persistent, atomic, multi-process safe
```
## How to switch
In `config/initializers/mozo_settings.rb`, change:
```ruby
# Mozo::Counter.connection = Mozo::DrbCounter.object # old
Mozo::Counter.connection = Mozo::Counter::Redis.new # new
```
That's it. All existing `Mozo::Counter.get/set/incr/decr` calls work unchanged.
## What Redis provides
- **Atomic INCR/DECR** — no race conditions
- **Persistence** — RDB snapshots + AOF, survives restarts
- **Multi-process** — all Puma workers share the same Redis
- **Already needed** — ActionCable uses Redis for pub/sub in production
- **Battle-tested** — millions of deployments
## Migration steps
1. `apt-get install redis-server` — already done on vmi3300327
2. `gem 'redis', '~> 5.0'` — added to Gemfile
3. Switch `Mozo::Counter.connection` — one-line change in mozo_settings.rb
4. Stop the DRb counter process (`drb_counter/drb_counter.rb`)
+2 -2
View File
@@ -19,12 +19,12 @@ module Mozo
autoload :DrbCounter
def self.broadcast_user(uid, event, data)
message = {channel: "/user/#{uid}", data: {event: event, data: data}}
message = {channel: "/user_#{uid}", data: {event: event, data: data}}
broadcaster.broadcast message
end
def self.broadcast_supplier(sid, event, data)
message = {channel: "/supplier/#{sid}", data: {event: event, data: data}}
message = {channel: "/supplier_#{sid}", data: {event: event, data: data}}
broadcaster.broadcast message
end
end
+1
View File
@@ -2,5 +2,6 @@ module Mozo
module Broadcaster
extend ActiveSupport::Autoload
autoload :Faye
autoload :ActionCable
end
end
+55
View File
@@ -0,0 +1,55 @@
# frozen_string_literal: true
module Mozo
module Broadcaster
# Drop-in replacement for Mozo::Broadcaster::Faye that uses
# Rails' built-in ActionCable instead of an external Faye process.
#
# Benefits over Faye:
# - Async by default (ActionCable.server.broadcast is non-blocking)
# - No extra gem / process / port to manage
# - Integrated with Rails authentication (cookies, sessions)
# - WebSocket native (no long-polling fallback needed with modern browsers)
#
# Channel naming: accepts both Faye format and underscore format:
# /user/123 or /user_123 → user_123
# /supplier/456 or /supplier_456 → supplier_456
#
# To use:
# Set Mozo.broadcaster = Mozo::Broadcaster::ActionCable.new
# in config/initializers/mozo_settings.rb
#
class ActionCable
CHANNEL_PREFIX_REMAP = {
%r{^/user[/_](.+)$} => 'user_\1',
%r{^/supplier[/_](.+)$} => 'supplier_\1'
}.freeze
def broadcast(message)
channel = message[:channel] || message['channel']
data = message[:data] || message['data']
remapped = remap_channel(channel)
unless remapped
Rails.logger.warn("[ACTION_CABLE] broadcast skipped: unknown channel #{channel}")
return
end
Rails.logger.debug("[ACTION_CABLE] broadcasting to #{remapped}: #{data.inspect}")
::ActionCable.server.broadcast(remapped, data)
rescue => e
Rails.logger.error("[ACTION_CABLE][ERROR] #{e.message}")
end
private
def remap_channel(channel)
CHANNEL_PREFIX_REMAP.each do |pattern, replacement|
return channel.sub(pattern, replacement) if channel.match?(pattern)
end
Rails.logger.warn("[ACTION_CABLE] Unknown channel format: #{channel}")
nil
end
end
end
end
+3
View File
@@ -1,5 +1,8 @@
module Mozo
module Counter
extend ActiveSupport::Autoload
autoload :Redis
mattr_accessor :connection
# mainly for testing purposes
+80
View File
@@ -0,0 +1,80 @@
# frozen_string_literal: true
module Mozo
module Counter
# Redis-backed counter adapter. Replaces the DrbCounter (in-memory,
# single-process, DRb-based) with a persistent, multi-process-safe,
# battle-tested key-value store.
#
# Benefits over DrbCounter:
# - Persistent (survives restarts, no CouchDB reload dance)
# - Atomic INCR/DECR (no race conditions between Puma workers)
# - No separate process to manage (Redis is already needed for ActionCable)
# - Production-ready, widely deployed
#
# Usage:
# Mozo::Counter.connection = Mozo::Counter::Redis.new
# # or with custom config:
# Mozo::Counter.connection = Mozo::Counter::Redis.new(url: "redis://localhost:6379/2")
#
class Redis
def initialize(url: nil)
require 'redis'
@redis = ::Redis.new(url: url || ENV.fetch('REDIS_URL', 'redis://localhost:6379/7'))
end
def get(key, options = {})
value = @redis.get(key)
quiet = options[:quiet]
unless quiet
Rails.logger.debug("[REDIS_COUNTER] GET #{key} = #{value.inspect}")
end
value&.to_i || 0
rescue => e
Rails.logger.error("[REDIS_COUNTER] GET #{key} failed: #{e.message}")
0
end
def set(key, value)
Rails.logger.debug("[REDIS_COUNTER] SET #{key} = #{value}")
@redis.set(key, value)
rescue => e
Rails.logger.error("[REDIS_COUNTER] SET #{key} failed: #{e.message}")
value
end
def incr(key, options = {})
initial = options[:initial] || 1
Rails.logger.debug("[REDIS_COUNTER] INCR #{key}")
if @redis.exists?(key)
@redis.incr(key)
else
@redis.set(key, initial)
initial
end
rescue => e
Rails.logger.error("[REDIS_COUNTER] INCR #{key} failed: #{e.message}")
initial
end
def decr(key, options = {})
initial = options[:initial] || 0
Rails.logger.debug("[REDIS_COUNTER] DECR #{key}")
if @redis.exists?(key)
@redis.decr(key)
else
@redis.set(key, initial)
initial
end
rescue => e
Rails.logger.error("[REDIS_COUNTER] DECR #{key} failed: #{e.message}")
initial
end
def flush
Rails.logger.debug("[REDIS_COUNTER] FLUSHDB")
@redis.flushdb
end
end
end
end