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