SolidCableMongoidAdapter
A production-ready Action Cable subscription adapter that uses MongoDB (via Mongoid) as a durable, cross-process broadcast backend with MongoDB Change Streams support.
Features
- Durable Message Storage: Persists broadcasts in MongoDB with automatic expiration via TTL indexes
- Real-time Delivery: Uses MongoDB Change Streams for instant message delivery across processes
- High Availability: Automatic reconnection with exponential backoff
- Resume Token Support: Continuity across reconnections without message loss
- Fallback Polling: Gracefully degrades to polling on standalone MongoDB (not recommended for production)
- Thread-Safe: Dedicated listener thread per server process
- Rails 7+ & 8+ Compatible: Works with modern Rails applications
Requirements
- Ruby: 2.7 or higher
- Rails: 7.0 or higher (supports Rails 8+)
- MongoDB: 4.0 or higher
- Mongoid: 7.0 or higher
- MongoDB Replica Set: Required for Change Streams (even single-node replica sets work)
Important: MongoDB Replica Set Requirement
This adapter requires MongoDB to be configured as a replica set to use Change Streams for real-time message delivery. A single-node replica set is sufficient for development and smaller deployments.
Converting Standalone MongoDB to Single-Node Replica Set
# 1. Stop MongoDB
sudo systemctl stop mongod
# 2. Edit /etc/mongod.conf and add:
replication:
replSetName: "rs0"
# 3. Start MongoDB
sudo systemctl start mongod
# 4. Connect with mongosh and initialize
mongosh
rs.initiate()For Docker/Docker Compose:
version: '3.8'
services:
mongodb:
image: mongo:7
command: --replSet rs0
ports:
- "27017:27017"
healthcheck:
test: mongosh --eval "rs.status()" || mongosh --eval "rs.initiate()"
interval: 10s
timeout: 5s
retries: 5Installation
Add this line to your application's Gemfile:
gem 'solid_cable_mongoid_adapter'And then execute:
$ bundle installOr install it yourself as:
$ gem install solid_cable_mongoid_adapterUsage
Configuration
Edit config/cable.yml:
production:
adapter: solid_mongoid
collection_name: "action_cable_messages" # default
expiration: 300 # TTL in seconds, default: 300
reconnect_delay: 1.0 # initial retry delay, default: 1.0
max_reconnect_delay: 60.0 # max retry delay, default: 60.0
poll_interval_ms: 500 # polling fallback interval, default: 500
poll_batch_limit: 200 # max messages per poll, default: 200
require_replica_set: true # enforce replica set, default: true
write_concern: 1 # set rite concern for broadcasts, 0, 1, etc..
development:
adapter: solid_mongoid
collection_name: "action_cable_messages_dev"
require_replica_set: false # can disable for dev if needed
test:
adapter: testMongoid Configuration
Ensure your config/mongoid.yml is properly configured:
production:
clients:
default:
uri: <%= ENV['MONGODB_URI'] %>
options:
max_pool_size: 50
min_pool_size: 5
wait_queue_timeout: 5
connect_timeout: 10
socket_timeout: 10
server_selection_timeout: 10
# Replica set configuration
replica_set: rs0
read:
mode: :primary_preferred
write:
w: 1Environment Variables
# MongoDB connection
export MONGODB_URI="mongodb://localhost:27017/myapp_production"
# Optional: Override polling settings
export POLL_INTERVAL_MS=500
export POLL_BATCH_LIMIT=200Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
adapter |
String | - | Must be solid_mongoid
|
collection_name |
String | action_cable_messages |
MongoDB collection name |
expiration |
Integer | 300 |
Message TTL in seconds |
reconnect_delay |
Float | 1.0 |
Initial reconnect delay in seconds |
max_reconnect_delay |
Float | 60.0 |
Maximum reconnect delay (exponential backoff cap) |
poll_interval_ms |
Integer | 500 |
Polling interval when Change Streams unavailable |
poll_batch_limit |
Integer | 200 |
Max messages fetched per poll iteration |
require_replica_set |
Boolean | true |
Enforce replica set requirement |
write_concern |
Integer | 1 |
MongoDB write concern (0=fire-and-forget, 1=acknowledged, 2+=replicas) |
Write Concern Configuration
The write_concern option controls MongoDB's write acknowledgment behavior:
write_concern: 1 (Default - Recommended)
- MongoDB acknowledges writes
- Guarantees message persistence
- Throughput: ~540 msg/sec
- Use for: Production, critical messages, reliable delivery
write_concern: 0 (High-Performance)
- Fire-and-forget, no acknowledgment
- 4-9x faster throughput (~2000-5000 msg/sec)
- Trade-off: Silent failures, potential message loss
- Use for: High-volume ephemeral data (chat, presence, typing indicators)
Example Configuration:
# Conservative (default) - guaranteed delivery
production:
adapter: solid_mongoid
write_concern: 1 # Wait for acknowledgment
# High-performance - ephemeral messages
production_high_volume:
adapter: solid_mongoid
write_concern: 0 # Fire-and-forget
# ⚠️ Warning: Message loss possible during failuresBenchmark Comparison:
| Write Concern | Throughput | Latency | Use Case |
|---|---|---|---|
| w=1 (default) | ~540 msg/sec | ~1.8ms | Critical messages, guaranteed delivery |
| w=0 (fast) | ~2000+ msg/sec | ~0.3ms | Chat, presence, ephemeral updates |
See Benchmark 7 for detailed performance comparison.
Performance
Benchmarks
Run the included benchmark suite to measure performance on your system:
With Docker (Recommended):
# Automatically starts MongoDB replica set, runs benchmarks, and cleans up
./benchmark/run_benchmark.shManual (requires MongoDB replica set on localhost:27017):
bundle exec ruby benchmark/benchmark.rbTypical Results (M1 Mac, MongoDB 7.0, local replica set):
| Metric | Value |
|---|---|
| Broadcast latency (100B) | ~1-2ms avg, <3ms p95 |
| Broadcast latency (1KB) | ~2ms avg, <4ms p95 |
| Broadcast latency (10KB) | ~2-3ms avg, <4ms p95 |
| Broadcast latency (100KB) | ~4-5ms avg, <6ms p95 |
| Throughput (10k messages) | 500-600 messages/sec |
| Throughput (100k messages) | 400-500 messages/sec (optional test) |
| Subscribe/Unsubscribe | <1ms |
| Instrumentation overhead | ~2ms per event |
High-Volume Test:
# Run with 100k messages (takes 2-5 minutes)
BENCHMARK_HIGH_VOLUME=true ./benchmark/run_benchmark.shChannel Filtering Impact:
| Scenario | Without Filtering | With Filtering | Improvement |
|---|---|---|---|
| 100 channels, subscribe to 10 | 100% traffic | 10% traffic | 90% reduction |
| 1000 channels, subscribe to 50 | 100% traffic | 5% traffic | 95% reduction |
Monitoring with ActiveSupport::Notifications
The adapter emits instrumentation events that you can subscribe to:
# config/initializers/cable_monitoring.rb
ActiveSupport::Notifications.subscribe("broadcast.solid_cable_mongoid") do |name, start, finish, id, payload|
duration = (finish - start) * 1000
Rails.logger.info "Broadcast to #{payload[:channel]}: #{duration.round(2)}ms (#{payload[:size]} bytes)"
# Send to your metrics system
StatsD.histogram("cable.broadcast.duration", duration, tags: ["channel:#{payload[:channel]}"])
end
ActiveSupport::Notifications.subscribe("message_received.solid_cable_mongoid") do |name, start, finish, id, payload|
duration = (finish - start) * 1000
Rails.logger.info "Message received on #{payload[:channel]}: #{duration.round(2)}ms, " \
"#{payload[:subscriber_count]} subscribers"
end
ActiveSupport::Notifications.subscribe("subscribe.solid_cable_mongoid") do |_name, _start, _finish, _id, payload|
Rails.logger.info "Subscribed to #{payload[:channel]} (#{payload[:total_channels]} total channels)"
StatsD.increment("cable.subscriptions", tags: ["channel:#{payload[:channel]}"])
end
ActiveSupport::Notifications.subscribe("broadcast_error.solid_cable_mongoid") do |_name, _start, _finish, _id, payload|
Rails.logger.error "Broadcast error on #{payload[:channel]}: #{payload[:error]}"
StatsD.increment("cable.errors", tags: ["type:broadcast", "error:#{payload[:error]}"])
endAvailable Events:
-
broadcast.solid_cable_mongoid- Message broadcast (payload:channel,size) -
message_received.solid_cable_mongoid- Message delivered to subscribers (payload:channel,subscriber_count) -
subscribe.solid_cable_mongoid- Channel subscription (payload:channel,total_channels) -
unsubscribe.solid_cable_mongoid- Channel unsubscription (payload:channel,total_channels) -
broadcast_error.solid_cable_mongoid- Broadcast failure (payload:channel,error) -
message_error.solid_cable_mongoid- Message delivery failure (payload:channel,error)
Production Deployment
Best Practices
- Use a Replica Set: Always use a replica set, even if it's a single node, to enable Change Streams
-
Connection Pooling: Configure appropriate pool sizes in
mongoid.yml -
Monitoring: Monitor the
action_cable_messagescollection size and TTL index -
Read Preference: Use
:primary_preferredfor read operations -
Write Concern: Use
w: 1for acceptable durability with good performance
MongoDB Atlas
production:
clients:
default:
uri: <%= ENV['MONGODB_ATLAS_URI'] %>
options:
max_pool_size: 100
retry_writes: true
retry_reads: trueKubernetes/Docker
# docker-compose.yml
version: '3.8'
services:
app:
environment:
MONGODB_URI: mongodb://mongodb:27017/myapp_production
depends_on:
mongodb:
condition: service_healthy
mongodb:
image: mongo:7
command: --replSet rs0
healthcheck:
test: mongosh --eval "rs.status()" || mongosh --eval "rs.initiate()"
interval: 10s
timeout: 5s
retries: 5How It Works
Architecture
-
Broadcast Phase: When a message is broadcast to a channel:
- Document inserted into MongoDB collection
- TTL index schedules automatic cleanup
- All server processes are notified via Change Streams
-
Listening Phase: Each server process:
- Maintains a Change Stream watching for inserts
- Receives new documents in real-time
- Dispatches to local Action Cable subscribers
- Maintains resume token for continuity
-
Fallback Mode: If Change Streams unavailable:
- Falls back to polling every
poll_interval_ms - Maintains
@last_seen_idto avoid replays - Periodically checks if Change Streams become available
- Falls back to polling every
Thread Safety
- One listener thread per Action Cable server process
- Callbacks posted to Action Cable event loop
- No shared state between processes
- Safe for Puma cluster mode, Passenger, and other forking servers
Resilience
- Automatic reconnection with exponential backoff
- Resume tokens prevent message loss across reconnects
- Graceful degradation to polling if needed
- Comprehensive error logging
Troubleshooting
"MongoDB replica set is required" Error
Problem: Getting SolidCableMongoidAdapter::ReplicaSetRequiredError
Solution: Convert your MongoDB to a replica set (see Requirements section) or set require_replica_set: false in cable.yml (not recommended for production)
Messages Not Being Delivered
Checklist:
- Verify MongoDB replica set is configured:
rs.status()in mongosh - Check Action Cable is mounted:
config/routes.rbshould havemount ActionCable.server => '/cable' - Verify collection exists:
db.action_cable_messages.find().limit(1) - Check logs for connection errors
- Ensure WebSocket connection is established in browser
High Memory Usage
Solutions:
- Reduce
expirationtime in cable.yml - Increase
poll_batch_limitif using polling - Monitor collection size:
db.action_cable_messages.stats() - Verify TTL index is working:
db.action_cable_messages.getIndexes()
Connection Pool Exhaustion
Solutions:
- Increase
max_pool_sizein mongoid.yml - Reduce number of Action Cable connections per process
- Use connection pooling monitoring
Development
After checking out the repo, run:
bundle install
bundle exec rake spec
bundle exec rubocopTo install this gem onto your local machine:
bundle exec rake installTesting
# Run all tests
bundle exec rspec
# Run with coverage
COVERAGE=true bundle exec rspec
# Run specific test
bundle exec rspec spec/adapter_spec.rbContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/washu/solid_cable_mongoid_adapter.
- Fork it
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create new Pull Request
License
The gem is available as open source under the terms of the MIT License.
Credits
Created and maintained by [Sal Scotto]
Based on the solid_cable pattern and adapted for MongoDB with production-grade features.