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
This commit is contained in:
@@ -7,6 +7,7 @@ source 'https://rubygems.org'
|
|||||||
gem 'rails', '~> 8.1.1'
|
gem 'rails', '~> 8.1.1'
|
||||||
#gem 'rails', '7.1.1'
|
#gem 'rails', '7.1.1'
|
||||||
gem 'rack-cors', require: 'rack/cors'
|
gem 'rack-cors', require: 'rack/cors'
|
||||||
|
gem 'redis', '~> 5.0'
|
||||||
|
|
||||||
# Bundle edge Rails instead:
|
# Bundle edge Rails instead:
|
||||||
# gem 'rails', git: 'git://github.com/rails/rails.git'
|
# gem 'rails', git: 'git://github.com/rails/rails.git'
|
||||||
|
|||||||
+7
-8
@@ -1,9 +1,9 @@
|
|||||||
# ActionCable configuration for real-time broadcasting.
|
# ActionCable configuration for real-time broadcasting.
|
||||||
#
|
#
|
||||||
# Development/Test: async adapter (in-process, no external dependency).
|
# Development: async adapter (in-process, no external dependency).
|
||||||
# Production: async is fine for single-server deployments.
|
# Test: test adapter.
|
||||||
# Switch to Redis (`redis://...`) if scaling to multiple Puma workers
|
# Production: Redis adapter — required for multi-worker deployments.
|
||||||
# where broadcasts need to reach clients connected to different workers.
|
# Redis is also used for Mozo::Counter (replacing DrbCounter).
|
||||||
#
|
#
|
||||||
development:
|
development:
|
||||||
adapter: async
|
adapter: async
|
||||||
@@ -12,7 +12,6 @@ test:
|
|||||||
adapter: test
|
adapter: test
|
||||||
|
|
||||||
production:
|
production:
|
||||||
adapter: async
|
adapter: redis
|
||||||
# adapter: redis
|
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
|
||||||
# url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
|
channel_prefix: mozo_backend
|
||||||
# channel_prefix: mozo_backend_production
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ else
|
|||||||
Mozo.user_url = 'https://user.mozo.bar'
|
Mozo.user_url = 'https://user.mozo.bar'
|
||||||
end
|
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
|
||||||
|
|
||||||
# use the connection from couchbase-structures/documents
|
# Counter: swap DrbCounter ↔ Redis
|
||||||
# will be overwritten in the specs since flushing the real
|
# Mozo::Counter.connection = Mozo::DrbCounter.object # current (DRb in-memory)
|
||||||
# thing is difficult
|
# Mozo::Counter.connection = Mozo::Counter::Redis.new # new (persistent, multi-process)
|
||||||
# Mozo::Counter.connection = $cb unless Rails.env.test?
|
|
||||||
|
|
||||||
# Use the Drb counter
|
|
||||||
Mozo::Counter.connection = Mozo::DrbCounter.object unless Rails.env.test?
|
Mozo::Counter.connection = Mozo::DrbCounter.object unless Rails.env.test?
|
||||||
|
|||||||
@@ -87,3 +87,63 @@ Once stable:
|
|||||||
- **Simpler deploys** — one less service to manage
|
- **Simpler deploys** — one less service to manage
|
||||||
- **WebSocket native** — no long-polling fallback complexity
|
- **WebSocket native** — no long-polling fallback complexity
|
||||||
- **Rails auth** — cookies/sessions work automatically
|
- **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`)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
module Mozo
|
module Mozo
|
||||||
module Counter
|
module Counter
|
||||||
|
extend ActiveSupport::Autoload
|
||||||
|
autoload :Redis
|
||||||
|
|
||||||
mattr_accessor :connection
|
mattr_accessor :connection
|
||||||
|
|
||||||
# mainly for testing purposes
|
# mainly for testing purposes
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user