From 1f52448253883ebdaf0db5a3c9de354af5a4451e Mon Sep 17 00:00:00 2001 From: root Date: Sun, 17 May 2026 15:25:49 +0200 Subject: [PATCH 01/16] feat(broadcasting): add ActionCable adapter + fix model broadcast anti-pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/channels/application_cable/connection.rb | 36 ++++++++ app/channels/mozo_channel.rb | 41 +++++++++ app/models/concerns/broadcastable.rb | 29 +++++++ config/cable.yml | 18 ++++ config/initializers/model_broadcast.rb | 31 +++++-- config/routes.rb | 4 + docs/broadcasting-migration.md | 89 ++++++++++++++++++++ lib/mozo/broadcaster.rb | 1 + lib/mozo/broadcaster/action_cable.rb | 51 +++++++++++ 9 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/mozo_channel.rb create mode 100644 app/models/concerns/broadcastable.rb create mode 100644 config/cable.yml create mode 100644 docs/broadcasting-migration.md create mode 100644 lib/mozo/broadcaster/action_cable.rb diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 00000000..98302857 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -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 diff --git a/app/channels/mozo_channel.rb b/app/channels/mozo_channel.rb new file mode 100644 index 00000000..ab8d4bab --- /dev/null +++ b/app/channels/mozo_channel.rb @@ -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 diff --git a/app/models/concerns/broadcastable.rb b/app/models/concerns/broadcastable.rb new file mode 100644 index 00000000..a2b89113 --- /dev/null +++ b/app/models/concerns/broadcastable.rb @@ -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 diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 00000000..503efb4a --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,18 @@ +# ActionCable configuration for real-time broadcasting. +# +# Development/Test: async adapter (in-process, no external dependency). +# Production: async is fine for single-server deployments. +# Switch to Redis (`redis://...`) if scaling to multiple Puma workers +# where broadcasts need to reach clients connected to different workers. +# +development: + adapter: async + +test: + adapter: test + +production: + adapter: async + # adapter: redis + # url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + # channel_prefix: mozo_backend_production diff --git a/config/initializers/model_broadcast.rb b/config/initializers/model_broadcast.rb index 68301256..bb505c41 100644 --- a/config/initializers/model_broadcast.rb +++ b/config/initializers/model_broadcast.rb @@ -1,12 +1,31 @@ -#TODO: this is really ugly, can cause memory leaks and much more bad stuff. We need a new broadcaster.... +# frozen_string_literal: true + +# Broadcast shim: provides broadcast_supplier / broadcast_user to all +# SimplyStored::Couch models without requiring ApplicationController.new. +# +# PREVIOUSLY (dangerous): +# ApplicationController.new.send(:broadcast_supplier, *args) +# → leaked controller instances, no request lifecycle +# +# NOW: +# Delegates directly to Mozo.broadcast_supplier / Mozo.broadcast_user +# which uses Mozo.broadcaster (configurable: Faye or ActionCable). +# +# MIGRATION PATH: +# Models should `include Broadcastable` directly instead of relying +# on this monkey-patch. Once all models include Broadcastable, this +# initializer can be removed. +# require 'simply_stored/couch' + module ModelBroadcast - def broadcast_supplier(*args) - ApplicationController.new.send(:broadcast_supplier, *args) + def broadcast_supplier(sid, event, data = {}) + Mozo.broadcast_supplier(sid, event, data) end - def broadcast_user(*args) - ApplicationController.new.send(:broadcast_user, *args) + + def broadcast_user(uid, event, data = {}) + Mozo.broadcast_user(uid, event, data) end end + SimplyStored::Couch.send(:include, ModelBroadcast) -#SimplyStored::Couch.send(:extend, ModelBroadcast) # this should never happen!!! diff --git a/config/routes.rb b/config/routes.rb index 750d12e6..6128d1cc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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: { diff --git a/docs/broadcasting-migration.md b/docs/broadcasting-migration.md new file mode 100644 index 00000000..2ba2c972 --- /dev/null +++ b/docs/broadcasting-migration.md @@ -0,0 +1,89 @@ +# 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 diff --git a/lib/mozo/broadcaster.rb b/lib/mozo/broadcaster.rb index 1522a0b9..ed32275c 100644 --- a/lib/mozo/broadcaster.rb +++ b/lib/mozo/broadcaster.rb @@ -2,5 +2,6 @@ module Mozo module Broadcaster extend ActiveSupport::Autoload autoload :Faye + autoload :ActionCable end end diff --git a/lib/mozo/broadcaster/action_cable.rb b/lib/mozo/broadcaster/action_cable.rb new file mode 100644 index 00000000..2331490c --- /dev/null +++ b/lib/mozo/broadcaster/action_cable.rb @@ -0,0 +1,51 @@ +# 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 is kept compatible with the existing Faye convention: + # /user/:uid → user_ + # /supplier/:sid → supplier_ + # + # 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) + return unless remapped + + ::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 -- 2.43.0 From a755d8a205180f230cc4aff88a7d8f8e00573287 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 16:36:28 +0200 Subject: [PATCH 02/16] 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 --- app/models/list.rb | 1 + app/models/order.rb | 1 + config/initializers/model_broadcast.rb | 31 -------------------------- 3 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 config/initializers/model_broadcast.rb diff --git a/app/models/list.rb b/app/models/list.rb index 1c0ea8e7..77777155 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -1,6 +1,7 @@ class List include SimplyStored::Couch include ActiveModel::SerializerSupport + include Broadcastable include List::JoinRequests per_page_method :limit_value #kaminari diff --git a/app/models/order.rb b/app/models/order.rb index 9690d674..4fbb602b 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -1,6 +1,7 @@ class Order include SimplyStored::Couch include ActiveModel::SerializerSupport + include Broadcastable property :state, default: 'placed' # placed, active, delivered, cancelled, closed diff --git a/config/initializers/model_broadcast.rb b/config/initializers/model_broadcast.rb deleted file mode 100644 index bb505c41..00000000 --- a/config/initializers/model_broadcast.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -# Broadcast shim: provides broadcast_supplier / broadcast_user to all -# SimplyStored::Couch models without requiring ApplicationController.new. -# -# PREVIOUSLY (dangerous): -# ApplicationController.new.send(:broadcast_supplier, *args) -# → leaked controller instances, no request lifecycle -# -# NOW: -# Delegates directly to Mozo.broadcast_supplier / Mozo.broadcast_user -# which uses Mozo.broadcaster (configurable: Faye or ActionCable). -# -# MIGRATION PATH: -# Models should `include Broadcastable` directly instead of relying -# on this monkey-patch. Once all models include Broadcastable, this -# initializer can be removed. -# -require 'simply_stored/couch' - -module ModelBroadcast - 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 - -SimplyStored::Couch.send(:include, ModelBroadcast) -- 2.43.0 From 3e4bcc80c88cd6474e86c25e05714586f3272ed5 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 16:42:09 +0200 Subject: [PATCH 03/16] feat(counter): add Redis counter adapter, replace DrbCounter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Gemfile | 1 + config/cable.yml | 15 +++--- config/initializers/mozo_settings.rb | 12 ++--- docs/broadcasting-migration.md | 60 +++++++++++++++++++++ lib/mozo/counter.rb | 3 ++ lib/mozo/counter/redis.rb | 80 ++++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 lib/mozo/counter/redis.rb diff --git a/Gemfile b/Gemfile index 9c007f6f..5c123372 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/config/cable.yml b/config/cable.yml index 503efb4a..cd9c788c 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,9 +1,9 @@ # ActionCable configuration for real-time broadcasting. # -# Development/Test: async adapter (in-process, no external dependency). -# Production: async is fine for single-server deployments. -# Switch to Redis (`redis://...`) if scaling to multiple Puma workers -# where broadcasts need to reach clients connected to different workers. +# 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: async @@ -12,7 +12,6 @@ test: adapter: test production: - adapter: async - # adapter: redis - # url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> - # channel_prefix: mozo_backend_production + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: mozo_backend diff --git a/config/initializers/mozo_settings.rb b/config/initializers/mozo_settings.rb index 9319b046..65e01cd1 100644 --- a/config/initializers/mozo_settings.rb +++ b/config/initializers/mozo_settings.rb @@ -8,12 +8,12 @@ else Mozo.user_url = 'https://user.mozo.bar' end +# 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 -# 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 +# 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? diff --git a/docs/broadcasting-migration.md b/docs/broadcasting-migration.md index 2ba2c972..d5e3ae65 100644 --- a/docs/broadcasting-migration.md +++ b/docs/broadcasting-migration.md @@ -87,3 +87,63 @@ Once stable: - **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`) diff --git a/lib/mozo/counter.rb b/lib/mozo/counter.rb index ac4648d4..5f3c775e 100644 --- a/lib/mozo/counter.rb +++ b/lib/mozo/counter.rb @@ -1,5 +1,8 @@ module Mozo module Counter + extend ActiveSupport::Autoload + autoload :Redis + mattr_accessor :connection # mainly for testing purposes diff --git a/lib/mozo/counter/redis.rb b/lib/mozo/counter/redis.rb new file mode 100644 index 00000000..edbde9d9 --- /dev/null +++ b/lib/mozo/counter/redis.rb @@ -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/0')) + 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 -- 2.43.0 From 5dbb6dbeae03de0c36f1c210faf9c4be8de3ac79 Mon Sep 17 00:00:00 2001 From: Benjamin ter Kuile Date: Sun, 17 May 2026 09:01:50 -0500 Subject: [PATCH 04/16] Explicitly require action_cable --- config/application.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/application.rb b/config/application.rb index 6956fbfc..a0735421 100644 --- a/config/application.rb +++ b/config/application.rb @@ -5,6 +5,7 @@ require 'rails' #require 'active_record/railtie' require 'action_controller/railtie' require 'action_mailer/railtie' +require 'action_cable' #require 'active_resource/railtie' require 'rails/test_unit/railtie' require 'sprockets/railtie' -- 2.43.0 From 12836dd14b90faba22c9096142b9cb84461a3c58 Mon Sep 17 00:00:00 2001 From: Benjamin ter Kuile Date: Sun, 17 May 2026 10:48:05 -0500 Subject: [PATCH 05/16] Switch to Redis counter --- Gemfile.lock | 5 +++++ config/initializers/mozo_settings.rb | 3 ++- lib/mozo/counter/redis.rb | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8c04deda..341fb5cc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/config/initializers/mozo_settings.rb b/config/initializers/mozo_settings.rb index 65e01cd1..457c3de3 100644 --- a/config/initializers/mozo_settings.rb +++ b/config/initializers/mozo_settings.rb @@ -16,4 +16,5 @@ Mozo.broadcaster = Mozo::Broadcaster::Faye.new # 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::DrbCounter.object unless Rails.env.test? +Mozo::Counter.connection = Mozo::Counter::Redis.new unless Rails.env.test? # new (persistent, multi-process) diff --git a/lib/mozo/counter/redis.rb b/lib/mozo/counter/redis.rb index edbde9d9..f7c7b858 100644 --- a/lib/mozo/counter/redis.rb +++ b/lib/mozo/counter/redis.rb @@ -20,7 +20,7 @@ module Mozo class Redis def initialize(url: nil) require 'redis' - @redis = ::Redis.new(url: url || ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')) + @redis = ::Redis.new(url: url || ENV.fetch('REDIS_URL', 'redis://localhost:6379/7')) end def get(key, options = {}) -- 2.43.0 From 4bee13aae7992b0a511833957975778613fa16e3 Mon Sep 17 00:00:00 2001 From: Benjamin ter Kuile Date: Sun, 17 May 2026 11:44:53 -0500 Subject: [PATCH 06/16] activate ActionCable messaging instead of Faye --- config/initializers/mozo_settings.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/initializers/mozo_settings.rb b/config/initializers/mozo_settings.rb index 457c3de3..ed22cbfc 100644 --- a/config/initializers/mozo_settings.rb +++ b/config/initializers/mozo_settings.rb @@ -11,7 +11,8 @@ end # 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::Faye.new +Mozo.broadcaster = Mozo::Broadcaster::ActionCable.new # new (in-process async) # Counter: swap DrbCounter ↔ Redis # Mozo::Counter.connection = Mozo::DrbCounter.object # current (DRb in-memory) -- 2.43.0 From df04e99447c4428e9517928c631a62c2756e49b3 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 18:49:13 +0200 Subject: [PATCH 07/16] 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 --- config/initializers/action_cable.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 config/initializers/action_cable.rb diff --git a/config/initializers/action_cable.rb b/config/initializers/action_cable.rb new file mode 100644 index 00000000..ea50c416 --- /dev/null +++ b/config/initializers/action_cable.rb @@ -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 -- 2.43.0 From ee8861355bfac3339c23d1a0d5f7b63ba20e8144 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 19:06:00 +0200 Subject: [PATCH 08/16] 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 --- app/channels/application_cable/channel.rb | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 app/channels/application_cable/channel.rb diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 00000000..9aec2305 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end -- 2.43.0 From 383872b800a5f1711372674b55d553e6f3b5f129 Mon Sep 17 00:00:00 2001 From: Benjamin ter Kuile Date: Sun, 17 May 2026 12:07:11 -0500 Subject: [PATCH 09/16] require the action_cable engine instead of just the root --- config/application.rb | 2 +- config/environments/development.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/config/application.rb b/config/application.rb index a0735421..09aab1ef 100644 --- a/config/application.rb +++ b/config/application.rb @@ -5,7 +5,7 @@ require 'rails' #require 'active_record/railtie' require 'action_controller/railtie' require 'action_mailer/railtie' -require 'action_cable' +require 'action_cable/engine' #require 'active_resource/railtie' require 'rails/test_unit/railtie' require 'sprockets/railtie' diff --git a/config/environments/development.rb b/config/environments/development.rb index fee702d1..61b0516c 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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' -- 2.43.0 From 11ba8e7434d67e892dd43ab001440e10b9849134 Mon Sep 17 00:00:00 2001 From: Benjamin ter Kuile Date: Sun, 17 May 2026 12:24:07 -0500 Subject: [PATCH 10/16] channel naming convention change --- lib/mozo.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mozo.rb b/lib/mozo.rb index af986779..67f2de03 100644 --- a/lib/mozo.rb +++ b/lib/mozo.rb @@ -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 -- 2.43.0 From 7c69f0a0bcb5874df3d7d0ba76d1016ff78112cb Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 19:27:30 +0200 Subject: [PATCH 11/16] fix(action_cable): accept both /user/123 and /user_123 channel formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/mozo/broadcaster/action_cable.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/mozo/broadcaster/action_cable.rb b/lib/mozo/broadcaster/action_cable.rb index 2331490c..66bb43da 100644 --- a/lib/mozo/broadcaster/action_cable.rb +++ b/lib/mozo/broadcaster/action_cable.rb @@ -11,9 +11,9 @@ module Mozo # - Integrated with Rails authentication (cookies, sessions) # - WebSocket native (no long-polling fallback needed with modern browsers) # - # Channel naming is kept compatible with the existing Faye convention: - # /user/:uid → user_ - # /supplier/:sid → supplier_ + # 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 @@ -21,8 +21,8 @@ module Mozo # class ActionCable CHANNEL_PREFIX_REMAP = { - %r{^/user/(.+)$} => 'user_\1', - %r{^/supplier/(.+)$} => 'supplier_\1' + %r{^/user[/_](.+)$} => 'user_\1', + %r{^/supplier[/_](.+)$} => 'supplier_\1' }.freeze def broadcast(message) -- 2.43.0 From bdd1d248db386636b54ccce35f003ac811c04ad0 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 19:41:00 +0200 Subject: [PATCH 12/16] 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 --- lib/mozo/broadcaster/action_cable.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/mozo/broadcaster/action_cable.rb b/lib/mozo/broadcaster/action_cable.rb index 66bb43da..402dc9dd 100644 --- a/lib/mozo/broadcaster/action_cable.rb +++ b/lib/mozo/broadcaster/action_cable.rb @@ -30,8 +30,12 @@ module Mozo data = message[:data] || message['data'] remapped = remap_channel(channel) - return unless remapped + 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}") -- 2.43.0 From 4ad701c1a5015cf305301f1ad02f6a61467110f6 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 19:48:19 +0200 Subject: [PATCH 13/16] fix(broadcasting): broadcast on mark_helped! even when already false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/models/list.rb | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/models/list.rb b/app/models/list.rb index 77777155..ca3dfc92 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -236,23 +236,21 @@ class List def mark_helped! self.needs_help = false - if save - broadcast_users 'list_helped', id: id - broadcast_supplier supplier_id, 'list_helped', id: id - end + save + broadcast_users 'list_helped', id: id + broadcast_supplier supplier_id, 'list_helped', id: id end def remove_needs_payment! self.needs_payment = false - if save - broadcast_users 'remove_list_needs_payment', id: id - broadcast_supplier supplier_id, 'remove_list_needs_payment', id: id - end + save + broadcast_users 'remove_list_needs_payment', id: id + broadcast_supplier supplier_id, 'remove_list_needs_payment', id: id end def needs_payment! self.needs_payment = true - if save + save broadcast_users 'list_needs_payment', id: id broadcast_supplier supplier_id, 'list_needs_payment', id: id end -- 2.43.0 From 02df03282e27095d4c75dfe0fb68f5661c6d6dbb Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 20:12:22 +0200 Subject: [PATCH 14/16] Revert "fix(broadcasting): broadcast on mark_helped! even when already false" This reverts commit 4ad701c1a5015cf305301f1ad02f6a61467110f6. --- app/models/list.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/list.rb b/app/models/list.rb index ca3dfc92..77777155 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -236,21 +236,23 @@ class List def mark_helped! self.needs_help = false - save - broadcast_users 'list_helped', id: id - broadcast_supplier supplier_id, 'list_helped', id: id + if save + broadcast_users 'list_helped', id: id + broadcast_supplier supplier_id, 'list_helped', id: id + end end def remove_needs_payment! self.needs_payment = false - save - broadcast_users 'remove_list_needs_payment', id: id - broadcast_supplier supplier_id, 'remove_list_needs_payment', id: id + if save + broadcast_users 'remove_list_needs_payment', id: id + broadcast_supplier supplier_id, 'remove_list_needs_payment', id: id + end end def needs_payment! self.needs_payment = true - save + if save broadcast_users 'list_needs_payment', id: id broadcast_supplier supplier_id, 'list_needs_payment', id: id end -- 2.43.0 From 4a4e0764160421735f1739cf92d67e591b6eb8a4 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 20:16:01 +0200 Subject: [PATCH 15/16] fix(action_cable): use Redis adapter in development too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- config/cable.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/cable.yml b/config/cable.yml index cd9c788c..36d8ae70 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -6,7 +6,9 @@ # Redis is also used for Mozo::Counter (replacing DrbCounter). # development: - adapter: async + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/2" } %> + channel_prefix: mozo_backend_dev test: adapter: test -- 2.43.0 From c48f4d90410fcc895638d0a514dcb97656493dd8 Mon Sep 17 00:00:00 2001 From: BenClaw Date: Sun, 17 May 2026 21:08:38 +0200 Subject: [PATCH 16/16] fix(action_cable): allow employee to subscribe to supplier channel - Employee authenticates via auth_token, acts on behalf of a Supplier - Connection now accepts ?supplier_id=ID query param - identified_by :current_supplier_id added - MozoChannel#authorized? allows :employee to subscribe to supplier_ when current_supplier_id matches --- app/channels/application_cable/connection.rb | 10 +++++++++- app/channels/mozo_channel.rb | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 98302857..42cc9d21 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -4,7 +4,13 @@ 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 + # + # Auth flows: + # User app: ?auth_token= + # Supplier app: ?auth_token=&supplier_id= + # (Employee logs in, acts on behalf of a specific Supplier) + # + identified_by :current_user, :current_entity_type, :current_supplier_id def connect token = request.params[:auth_token].presence @@ -13,6 +19,8 @@ module ApplicationCable if (employee = Employee.find_by_authentication_token(token)) self.current_user = employee self.current_entity_type = :employee + # Employee acts on behalf of a supplier — passed as query param + self.current_supplier_id = request.params[:supplier_id] elsif (user = User.find_by_authentication_token(token)) self.current_user = user self.current_entity_type = :user diff --git a/app/channels/mozo_channel.rb b/app/channels/mozo_channel.rb index ab8d4bab..020ef399 100644 --- a/app/channels/mozo_channel.rb +++ b/app/channels/mozo_channel.rb @@ -31,7 +31,10 @@ class MozoChannel < ApplicationCable::Channel 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 + # Supplier app: Employee logs in, acts on behalf of a Supplier. + # The supplier_id is passed as a query param when connecting. + (connection.current_entity_type == :supplier && connection.current_user.id.to_s == id) || + (connection.current_entity_type == :employee && connection.current_supplier_id.to_s == id) when 'employee' connection.current_entity_type == :employee && connection.current_user.id.to_s == id else -- 2.43.0