solid_cable_mongoid_adapter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 859363aab566363aae944af0dcb524a9d5298cb4e56bf6ded6c2abf8618a568f
4
+ data.tar.gz: 4cf1bb50558c4e8b1f452b9a604f98d7626fb53c5a814fd1fa540bbdfe78c5f1
5
+ SHA512:
6
+ metadata.gz: 7cd25df03ed0b311c5cf9c621dfa8cd4db5aed42f9b16971de682aa85a4e2ce29d9f16d6df997a3179e51e82af70cfc40530bf8bcfea2eafe80c8c53bf8ef622
7
+ data.tar.gz: 7032517c894e1e8f602a602bbae5f90c6f93487593136cc596d36e2e3a42bc769198f0361b70621435c664845a3c93aaf4fe9befb1c1846e97e68f9fc4b92b97
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --color
3
+ --format documentation
data/.rubocop.yml ADDED
@@ -0,0 +1,45 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.7
3
+ NewCops: enable
4
+ Exclude:
5
+ - 'vendor/**/*'
6
+ - 'bin/**/*'
7
+ - 'tmp/**/*'
8
+
9
+ Style/Documentation:
10
+ Enabled: false
11
+
12
+ Style/StringLiterals:
13
+ EnforcedStyle: double_quotes
14
+
15
+ Style/StringLiteralsInInterpolation:
16
+ EnforcedStyle: double_quotes
17
+
18
+ Layout/LineLength:
19
+ Max: 120
20
+
21
+ Metrics/BlockLength:
22
+ Exclude:
23
+ - 'spec/**/*'
24
+ - '*.gemspec'
25
+
26
+ Metrics/MethodLength:
27
+ Max: 25
28
+ Exclude:
29
+ - 'lib/action_cable/subscription_adapter/solid_mongoid.rb'
30
+
31
+ Metrics/ClassLength:
32
+ Max: 250
33
+
34
+ Metrics/AbcSize:
35
+ Max: 25
36
+ Exclude:
37
+ - 'lib/action_cable/subscription_adapter/solid_mongoid.rb'
38
+
39
+ Metrics/CyclomaticComplexity:
40
+ Exclude:
41
+ - 'lib/action_cable/subscription_adapter/solid_mongoid.rb'
42
+
43
+ Metrics/PerceivedComplexity:
44
+ Exclude:
45
+ - 'lib/action_cable/subscription_adapter/solid_mongoid.rb'
data/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2025-02-09
9
+
10
+ ### Added
11
+ - Initial release of SolidCableMongoidAdapter
12
+ - MongoDB Change Streams support for real-time message delivery
13
+ - Automatic TTL-based message expiration
14
+ - Replica set requirement validation
15
+ - Exponential backoff for reconnection attempts
16
+ - Resume token support for continuity across reconnections
17
+ - Fallback polling mode for standalone MongoDB
18
+ - Comprehensive logging and error handling
19
+ - Thread-safe listener implementation
20
+ - Rails 7+ and Rails 8+ compatibility
21
+ - Production-grade code quality and documentation
22
+
23
+ ### Features
24
+ - Channel-based subscription management
25
+ - Configurable message expiration (TTL)
26
+ - Configurable reconnection delays with exponential backoff
27
+ - Configurable polling parameters for fallback mode
28
+ - Automatic collection and index creation
29
+ - Fork-safe operation (Passenger, Puma cluster mode)
30
+
31
+ ### Configuration Options
32
+ - `collection_name`: MongoDB collection name
33
+ - `expiration`: Message TTL in seconds
34
+ - `reconnect_delay`: Initial retry delay
35
+ - `max_reconnect_delay`: Maximum retry delay
36
+ - `poll_interval_ms`: Polling interval
37
+ - `poll_batch_limit`: Max messages per poll
38
+ - `require_replica_set`: Enforce replica set requirement
39
+
40
+ ## [Unreleased]
41
+
42
+ ### Planned
43
+ - Performance metrics and monitoring hooks
44
+ - Support for multiple MongoDB databases
45
+ - Message compression options
46
+ - Custom serialization support
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,85 @@
1
+ # Contributing to SolidCableMongoidAdapter
2
+
3
+ First off, thank you for considering contributing to SolidCableMongoidAdapter!
4
+
5
+ ## Code of Conduct
6
+
7
+ This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code.
8
+
9
+ ## How Can I Contribute?
10
+
11
+ ### Reporting Bugs
12
+
13
+ Before creating bug reports, please check existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:
14
+
15
+ * Use a clear and descriptive title
16
+ * Describe the exact steps which reproduce the problem
17
+ * Provide specific examples to demonstrate the steps
18
+ * Describe the behavior you observed after following the steps
19
+ * Explain which behavior you expected to see instead and why
20
+ * Include logs and error messages
21
+
22
+ ### Suggesting Enhancements
23
+
24
+ Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
25
+
26
+ * Use a clear and descriptive title
27
+ * Provide a step-by-step description of the suggested enhancement
28
+ * Provide specific examples to demonstrate the steps
29
+ * Describe the current behavior and explain which behavior you expected to see instead
30
+ * Explain why this enhancement would be useful
31
+
32
+ ### Pull Requests
33
+
34
+ * Fill in the required template
35
+ * Do not include issue numbers in the PR title
36
+ * Follow the Ruby styleguide (RuboCop)
37
+ * Include thoughtfully-worded, well-structured RSpec tests
38
+ * Document new code
39
+ * End all files with a newline
40
+
41
+ ## Development Setup
42
+
43
+ 1. Fork and clone the repository
44
+ 2. Install dependencies: `bundle install`
45
+ 3. Set up MongoDB replica set (see README)
46
+ 4. Run tests: `bundle exec rspec`
47
+ 5. Run linter: `bundle exec rubocop`
48
+
49
+ ## Testing
50
+
51
+ ```bash
52
+ # Run all tests
53
+ bundle exec rspec
54
+
55
+ # Run specific test file
56
+ bundle exec rspec spec/adapter_spec.rb
57
+
58
+ # Run with coverage
59
+ COVERAGE=true bundle exec rspec
60
+ ```
61
+
62
+ ## Style Guide
63
+
64
+ We use RuboCop for code style enforcement. Run `bundle exec rubocop` before committing.
65
+
66
+ ## Commit Messages
67
+
68
+ * Use the present tense ("Add feature" not "Added feature")
69
+ * Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
70
+ * Limit the first line to 72 characters or less
71
+ * Reference issues and pull requests liberally after the first line
72
+
73
+ ## Release Process
74
+
75
+ 1. Update version in `lib/solid_cable_mongoid_adapter/version.rb`
76
+ 2. Update CHANGELOG.md
77
+ 3. Commit changes
78
+ 4. Create git tag: `git tag v1.0.0`
79
+ 5. Push: `git push origin main --tags`
80
+ 6. Build gem: `gem build solid_cable_mongoid_adapter.gemspec`
81
+ 7. Publish: `gem push solid_cable_mongoid_adapter-1.0.0.gem`
82
+
83
+ ## Questions?
84
+
85
+ Feel free to open an issue with your question or contact the maintainers directly.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sal Scotto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # SolidCableMongoidAdapter
2
+
3
+ [![CI](https://github.com/washu/solid_cable_mongoid_adapter/actions/workflows/ci.yml/badge.svg)](https://github.com/washu/solid_cable_mongoid_adapter/actions/workflows/ci.yml)
4
+
5
+ A production-ready Action Cable subscription adapter that uses MongoDB (via Mongoid) as a durable, cross-process broadcast backend with MongoDB Change Streams support.
6
+
7
+ ## Features
8
+
9
+ - **Durable Message Storage**: Persists broadcasts in MongoDB with automatic expiration via TTL indexes
10
+ - **Real-time Delivery**: Uses MongoDB Change Streams for instant message delivery across processes
11
+ - **High Availability**: Automatic reconnection with exponential backoff
12
+ - **Resume Token Support**: Continuity across reconnections without message loss
13
+ - **Fallback Polling**: Gracefully degrades to polling on standalone MongoDB (not recommended for production)
14
+ - **Thread-Safe**: Dedicated listener thread per server process
15
+ - **Rails 7+ & 8+ Compatible**: Works with modern Rails applications
16
+
17
+ ## Requirements
18
+
19
+ - **Ruby**: 2.7 or higher
20
+ - **Rails**: 7.0 or higher (supports Rails 8+)
21
+ - **MongoDB**: 4.0 or higher
22
+ - **Mongoid**: 7.0 or higher
23
+ - **MongoDB Replica Set**: Required for Change Streams (even single-node replica sets work)
24
+
25
+ ### Important: MongoDB Replica Set Requirement
26
+
27
+ 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.
28
+
29
+ #### Converting Standalone MongoDB to Single-Node Replica Set
30
+
31
+ ```bash
32
+ # 1. Stop MongoDB
33
+ sudo systemctl stop mongod
34
+
35
+ # 2. Edit /etc/mongod.conf and add:
36
+ replication:
37
+ replSetName: "rs0"
38
+
39
+ # 3. Start MongoDB
40
+ sudo systemctl start mongod
41
+
42
+ # 4. Connect with mongosh and initialize
43
+ mongosh
44
+ rs.initiate()
45
+ ```
46
+
47
+ For Docker/Docker Compose:
48
+
49
+ ```yaml
50
+ version: '3.8'
51
+ services:
52
+ mongodb:
53
+ image: mongo:7
54
+ command: --replSet rs0
55
+ ports:
56
+ - "27017:27017"
57
+ healthcheck:
58
+ test: mongosh --eval "rs.status()" || mongosh --eval "rs.initiate()"
59
+ interval: 10s
60
+ timeout: 5s
61
+ retries: 5
62
+ ```
63
+
64
+ ## Installation
65
+
66
+ Add this line to your application's Gemfile:
67
+
68
+ ```ruby
69
+ gem 'solid_cable_mongoid_adapter'
70
+ ```
71
+
72
+ And then execute:
73
+
74
+ ```bash
75
+ $ bundle install
76
+ ```
77
+
78
+ Or install it yourself as:
79
+
80
+ ```bash
81
+ $ gem install solid_cable_mongoid_adapter
82
+ ```
83
+
84
+ ## Usage
85
+
86
+ ### Configuration
87
+
88
+ Edit `config/cable.yml`:
89
+
90
+ ```yaml
91
+ production:
92
+ adapter: solid_mongoid
93
+ collection_name: "action_cable_messages" # default
94
+ expiration: 300 # TTL in seconds, default: 300
95
+ reconnect_delay: 1.0 # initial retry delay, default: 1.0
96
+ max_reconnect_delay: 60.0 # max retry delay, default: 60.0
97
+ poll_interval_ms: 500 # polling fallback interval, default: 500
98
+ poll_batch_limit: 200 # max messages per poll, default: 200
99
+ require_replica_set: true # enforce replica set, default: true
100
+
101
+ development:
102
+ adapter: solid_mongoid
103
+ collection_name: "action_cable_messages_dev"
104
+ require_replica_set: false # can disable for dev if needed
105
+
106
+ test:
107
+ adapter: test
108
+ ```
109
+
110
+ ### Mongoid Configuration
111
+
112
+ Ensure your `config/mongoid.yml` is properly configured:
113
+
114
+ ```yaml
115
+ production:
116
+ clients:
117
+ default:
118
+ uri: <%= ENV['MONGODB_URI'] %>
119
+ options:
120
+ max_pool_size: 50
121
+ min_pool_size: 5
122
+ wait_queue_timeout: 5
123
+ connect_timeout: 10
124
+ socket_timeout: 10
125
+ server_selection_timeout: 10
126
+ # Replica set configuration
127
+ replica_set: rs0
128
+ read:
129
+ mode: :primary_preferred
130
+ write:
131
+ w: 1
132
+ ```
133
+
134
+ ### Environment Variables
135
+
136
+ ```bash
137
+ # MongoDB connection
138
+ export MONGODB_URI="mongodb://localhost:27017/myapp_production"
139
+
140
+ # Optional: Override polling settings
141
+ export POLL_INTERVAL_MS=500
142
+ export POLL_BATCH_LIMIT=200
143
+ ```
144
+
145
+ ## Configuration Options
146
+
147
+ | Option | Type | Default | Description |
148
+ |--------|------|---------|-------------|
149
+ | `adapter` | String | - | Must be `solid_mongoid` |
150
+ | `collection_name` | String | `action_cable_messages` | MongoDB collection name |
151
+ | `expiration` | Integer | `300` | Message TTL in seconds |
152
+ | `reconnect_delay` | Float | `1.0` | Initial reconnect delay in seconds |
153
+ | `max_reconnect_delay` | Float | `60.0` | Maximum reconnect delay (exponential backoff cap) |
154
+ | `poll_interval_ms` | Integer | `500` | Polling interval when Change Streams unavailable |
155
+ | `poll_batch_limit` | Integer | `200` | Max messages fetched per poll iteration |
156
+ | `require_replica_set` | Boolean | `true` | Enforce replica set requirement |
157
+
158
+ ## Production Deployment
159
+
160
+ ### Best Practices
161
+
162
+ 1. **Use a Replica Set**: Always use a replica set, even if it's a single node, to enable Change Streams
163
+ 2. **Connection Pooling**: Configure appropriate pool sizes in `mongoid.yml`
164
+ 3. **Monitoring**: Monitor the `action_cable_messages` collection size and TTL index
165
+ 4. **Read Preference**: Use `:primary_preferred` for read operations
166
+ 5. **Write Concern**: Use `w: 1` for acceptable durability with good performance
167
+
168
+ ### MongoDB Atlas
169
+
170
+ ```yaml
171
+ production:
172
+ clients:
173
+ default:
174
+ uri: <%= ENV['MONGODB_ATLAS_URI'] %>
175
+ options:
176
+ max_pool_size: 100
177
+ retry_writes: true
178
+ retry_reads: true
179
+ ```
180
+
181
+ ### Kubernetes/Docker
182
+
183
+ ```yaml
184
+ # docker-compose.yml
185
+ version: '3.8'
186
+ services:
187
+ app:
188
+ environment:
189
+ MONGODB_URI: mongodb://mongodb:27017/myapp_production
190
+ depends_on:
191
+ mongodb:
192
+ condition: service_healthy
193
+
194
+ mongodb:
195
+ image: mongo:7
196
+ command: --replSet rs0
197
+ healthcheck:
198
+ test: mongosh --eval "rs.status()" || mongosh --eval "rs.initiate()"
199
+ interval: 10s
200
+ timeout: 5s
201
+ retries: 5
202
+ ```
203
+
204
+ ## How It Works
205
+
206
+ ### Architecture
207
+
208
+ 1. **Broadcast Phase**: When a message is broadcast to a channel:
209
+ - Document inserted into MongoDB collection
210
+ - TTL index schedules automatic cleanup
211
+ - All server processes are notified via Change Streams
212
+
213
+ 2. **Listening Phase**: Each server process:
214
+ - Maintains a Change Stream watching for inserts
215
+ - Receives new documents in real-time
216
+ - Dispatches to local Action Cable subscribers
217
+ - Maintains resume token for continuity
218
+
219
+ 3. **Fallback Mode**: If Change Streams unavailable:
220
+ - Falls back to polling every `poll_interval_ms`
221
+ - Maintains `@last_seen_id` to avoid replays
222
+ - Periodically checks if Change Streams become available
223
+
224
+ ### Thread Safety
225
+
226
+ - One listener thread per Action Cable server process
227
+ - Callbacks posted to Action Cable event loop
228
+ - No shared state between processes
229
+ - Safe for Puma cluster mode, Passenger, and other forking servers
230
+
231
+ ### Resilience
232
+
233
+ - Automatic reconnection with exponential backoff
234
+ - Resume tokens prevent message loss across reconnects
235
+ - Graceful degradation to polling if needed
236
+ - Comprehensive error logging
237
+
238
+ ## Troubleshooting
239
+
240
+ ### "MongoDB replica set is required" Error
241
+
242
+ **Problem**: Getting `SolidCableMongoidAdapter::ReplicaSetRequiredError`
243
+
244
+ **Solution**: Convert your MongoDB to a replica set (see Requirements section) or set `require_replica_set: false` in cable.yml (not recommended for production)
245
+
246
+ ### Messages Not Being Delivered
247
+
248
+ **Checklist**:
249
+ 1. Verify MongoDB replica set is configured: `rs.status()` in mongosh
250
+ 2. Check Action Cable is mounted: `config/routes.rb` should have `mount ActionCable.server => '/cable'`
251
+ 3. Verify collection exists: `db.action_cable_messages.find().limit(1)`
252
+ 4. Check logs for connection errors
253
+ 5. Ensure WebSocket connection is established in browser
254
+
255
+ ### High Memory Usage
256
+
257
+ **Solutions**:
258
+ 1. Reduce `expiration` time in cable.yml
259
+ 2. Increase `poll_batch_limit` if using polling
260
+ 3. Monitor collection size: `db.action_cable_messages.stats()`
261
+ 4. Verify TTL index is working: `db.action_cable_messages.getIndexes()`
262
+
263
+ ### Connection Pool Exhaustion
264
+
265
+ **Solutions**:
266
+ 1. Increase `max_pool_size` in mongoid.yml
267
+ 2. Reduce number of Action Cable connections per process
268
+ 3. Use connection pooling monitoring
269
+
270
+ ## Development
271
+
272
+ After checking out the repo, run:
273
+
274
+ ```bash
275
+ bundle install
276
+ bundle exec rake spec
277
+ bundle exec rubocop
278
+ ```
279
+
280
+ To install this gem onto your local machine:
281
+
282
+ ```bash
283
+ bundle exec rake install
284
+ ```
285
+
286
+ ## Testing
287
+
288
+ ```bash
289
+ # Run all tests
290
+ bundle exec rspec
291
+
292
+ # Run with coverage
293
+ COVERAGE=true bundle exec rspec
294
+
295
+ # Run specific test
296
+ bundle exec rspec spec/adapter_spec.rb
297
+ ```
298
+
299
+ ## Contributing
300
+
301
+ Bug reports and pull requests are welcome on GitHub at https://github.com/washu/solid_cable_mongoid_adapter.
302
+
303
+ 1. Fork it
304
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
305
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
306
+ 4. Push to the branch (`git push origin my-new-feature`)
307
+ 5. Create new Pull Request
308
+
309
+ ## License
310
+
311
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
312
+
313
+ ## Credits
314
+
315
+ Created and maintained by [Aztec Software](https://aztecsoftware.com).
316
+
317
+ Based on the solid_cable pattern and adapted for MongoDB with production-grade features.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/subscription_adapter/base"
4
+ require "action_cable/subscription_adapter/channel_prefix"
5
+ require "action_cable/subscription_adapter/subscriber_map"
6
+ require "mongoid"
7
+ require "securerandom"
8
+
9
+ module ActionCable
10
+ module SubscriptionAdapter
11
+ # SolidMongoid is an Action Cable subscription adapter that uses MongoDB (via Mongoid's client)
12
+ # as a durable, cross-process broadcast backend.
13
+ #
14
+ # ## Requirements
15
+ # - MongoDB must be configured as a replica set (even if single-node)
16
+ # - Change Streams require replica set or sharded cluster
17
+ #
18
+ # ## Features
19
+ # - Persists each broadcast as a document in a collection with TTL index
20
+ # - Uses MongoDB Change Streams for real-time message delivery
21
+ # - Falls back to polling on standalone MongoDB (not recommended for production)
22
+ # - Automatic reconnection with exponential backoff
23
+ # - Resume token support for continuity across reconnections
24
+ #
25
+ # ## Configuration
26
+ # Configure in `config/cable.yml` under the current environment:
27
+ #
28
+ # production:
29
+ # adapter: solid_mongoid
30
+ # collection_name: "action_cable_messages" # default
31
+ # expiration: 300 # seconds, default: 300
32
+ # reconnect_delay: 1.0 # seconds, default: 1.0
33
+ # max_reconnect_delay: 60.0 # seconds, default: 60.0
34
+ # poll_interval_ms: 500 # milliseconds, default: 500
35
+ # poll_batch_limit: 200 # default: 200
36
+ # require_replica_set: true # default: true
37
+ #
38
+ # ## Thread Safety
39
+ # The adapter is thread-safe and maintains a dedicated listener thread per server process.
40
+ class SolidMongoid < Base
41
+ prepend ChannelPrefix
42
+
43
+ # Initialize the adapter and ensure the Mongo collection/index are ready.
44
+ # Validates replica set requirement if configured, logs a warning if not available.
45
+ #
46
+ # @return [void]
47
+ def initialize(*)
48
+ super
49
+ @listener = nil
50
+ validate_replica_set!
51
+ ensure_collection_state
52
+ logger.info "SolidCableMongoid: initialized; collection=#{collection_name.inspect}, pid=#{Process.pid}"
53
+ end
54
+
55
+ # Broadcast a payload to a channel by inserting a document into MongoDB.
56
+ # All listeners (processes/servers) will receive it through Change Streams or polling
57
+ # and rebroadcast to local subscribers.
58
+ #
59
+ # @param channel [String, Symbol] the channel identifier
60
+ # @param payload [String] the raw message payload (Action Cable provides a JSON string)
61
+ # @return [Boolean] true if successful, false on error
62
+ def broadcast(channel, payload)
63
+ collection.insert_one(
64
+ {
65
+ channel: channel.to_s,
66
+ message: payload,
67
+ created_at: Time.now.utc,
68
+ _expires: Time.now.utc + expiration
69
+ }
70
+ )
71
+ true
72
+ rescue Mongo::Error => e
73
+ logger.error "SolidCableMongoid: broadcast error (#{e.class}): #{e.message}"
74
+ false
75
+ rescue StandardError => e
76
+ logger.error "SolidCableMongoid: unexpected broadcast error (#{e.class}): #{e.message}"
77
+ false
78
+ end
79
+
80
+ # Subscribe a callback to a channel.
81
+ # The `success_callback` (if provided) is executed exactly once by `SubscriberMap` upon subscription success.
82
+ #
83
+ # @param channel [String, Symbol] the channel identifier
84
+ # @param callback [Proc] the block to invoke with each received message
85
+ # @param success_callback [Proc, nil] optional block to call once on successful subscription
86
+ # @return [void]
87
+ def subscribe(channel, callback, success_callback = nil)
88
+ listener.add_subscriber(channel, callback, success_callback)
89
+ end
90
+
91
+ # Unsubscribe a callback from a channel.
92
+ #
93
+ # @param channel [String, Symbol] the channel identifier
94
+ # @param callback [Proc] the previously registered callback
95
+ # @return [void]
96
+ def unsubscribe(channel, callback)
97
+ listener.remove_subscriber(channel, callback)
98
+ end
99
+
100
+ # Shut down the listener thread and release resources.
101
+ #
102
+ # @return [void]
103
+ def shutdown
104
+ listener&.shutdown
105
+ end
106
+
107
+ # Validate that MongoDB is configured as a replica set if required.
108
+ # Logs a warning and falls back to polling if not configured.
109
+ #
110
+ # @return [void]
111
+ def validate_replica_set!
112
+ return unless require_replica_set?
113
+
114
+ return if replica_set_configured?
115
+
116
+ logger.warn "SolidCableMongoid: MongoDB is not configured as a replica set. " \
117
+ "Change Streams are unavailable; falling back to polling mode. " \
118
+ "Set require_replica_set: false in cable.yml to disable this check."
119
+ end
120
+
121
+ # Check if MongoDB is configured as a replica set.
122
+ #
123
+ # @return [Boolean] true if replica set is configured
124
+ def replica_set_configured?
125
+ client = Mongoid.default_client
126
+ hello = begin
127
+ client.database.command({ hello: 1 }).first
128
+ rescue StandardError
129
+ nil
130
+ end
131
+ hello ||= begin
132
+ client.database.command({ ismaster: 1 }).first
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ !!hello&.[]("setName")
137
+ rescue StandardError => e
138
+ logger.warn "SolidCableMongoid: unable to check replica set status (#{e.class}): #{e.message}"
139
+ false
140
+ end
141
+
142
+ # Ensure the MongoDB collection and indexes are in the expected state.
143
+ #
144
+ # @return [void]
145
+ def ensure_collection_state
146
+ db = Mongoid.default_client.database
147
+
148
+ # 1. Ensure collection exists
149
+ unless db.collection_names.include?(collection_name)
150
+ db.create_collection(collection_name)
151
+ logger.info "SolidCableMongoid: created collection #{collection_name.inspect}"
152
+ end
153
+
154
+ coll = db.collection(collection_name)
155
+
156
+ # 2. Create TTL index for automatic message expiration
157
+ begin
158
+ coll.indexes.create_one(
159
+ { _expires: 1 },
160
+ expire_after_seconds: 0,
161
+ name: "auto_expire",
162
+ partial_filter_expression: {
163
+ "_expires" => {
164
+ "$exists" => true,
165
+ "$type" => 9 # BSON Date type
166
+ }
167
+ }
168
+ )
169
+ logger.debug "SolidCableMongoid: TTL index ensured"
170
+ rescue Mongo::Error::OperationFailure => e
171
+ # Index may already exist with different options
172
+ if e.message.include?("already exists")
173
+ logger.debug "SolidCableMongoid: TTL index already exists"
174
+ else
175
+ logger.warn "SolidCableMongoid: failed to create TTL index: #{e.message}"
176
+ end
177
+ end
178
+
179
+ # 3. Create index on channel for query performance
180
+ begin
181
+ coll.indexes.create_one(
182
+ { channel: 1, _id: 1 },
183
+ name: "channel_id_index"
184
+ )
185
+ logger.debug "SolidCableMongoid: channel index ensured"
186
+ rescue Mongo::Error::OperationFailure => e
187
+ if e.message.include?("already exists")
188
+ logger.debug "SolidCableMongoid: channel index already exists"
189
+ else
190
+ logger.warn "SolidCableMongoid: failed to create channel index: #{e.message}"
191
+ end
192
+ end
193
+ rescue StandardError => e
194
+ logger.error "SolidCableMongoid: failed to ensure collection state: #{e.message}"
195
+ end
196
+
197
+ # --- Configuration accessors -------------------------------------------------
198
+
199
+ # Obtain the Mongo collection used for Action Cable messages.
200
+ # Not memoized to avoid issues with forking (e.g., Passenger, Puma cluster mode).
201
+ #
202
+ # @return [Mongo::Collection]
203
+ def collection
204
+ Mongoid.default_client.database.collection(collection_name)
205
+ end
206
+
207
+ # The name of the Mongo collection storing broadcasts.
208
+ #
209
+ # @return [String]
210
+ def collection_name
211
+ @server.config.cable.fetch("collection_name", "action_cable_messages")
212
+ end
213
+
214
+ # Message expiration time in seconds used by the TTL index.
215
+ #
216
+ # @return [Integer]
217
+ def expiration
218
+ @server.config.cable.fetch("expiration", 300).to_i
219
+ end
220
+
221
+ # Whether to require a replica set configuration.
222
+ #
223
+ # @return [Boolean]
224
+ def require_replica_set?
225
+ @server.config.cable.fetch("require_replica_set", true)
226
+ end
227
+
228
+ # The logger from the Action Cable server.
229
+ #
230
+ # @return [Logger]
231
+ def logger
232
+ @server.logger
233
+ end
234
+
235
+ # The Action Cable server instance.
236
+ #
237
+ # @return [ActionCable::Server::Base]
238
+ attr_reader :server
239
+
240
+ # The singleton listener for this server process. Lazily instantiated and
241
+ # synchronized through the server's mutex.
242
+ #
243
+ # @return [Listener]
244
+ def listener
245
+ @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) }
246
+ end
247
+
248
+ # Listener consumes MongoDB inserts for this adapter and dispatches them
249
+ # to local Action Cable subscribers. It prefers MongoDB Change Streams
250
+ # when available and transparently falls back to polling on standalone
251
+ # deployments.
252
+ #
253
+ # ## Design
254
+ # - **Threaded**: a dedicated background thread runs the main loop
255
+ # - **Delivery**: callbacks are posted onto the Action Cable event loop for thread-safety
256
+ # - **Resilience**: on errors, uses exponential backoff before retry
257
+ # - **Continuity**: maintains `@resume_token` to resume Change Streams without message loss
258
+ #
259
+ # ## Configuration
260
+ # - `reconnect_delay` [Float] initial delay in seconds before retry (default: 1.0)
261
+ # - `max_reconnect_delay` [Float] maximum delay in seconds (default: 60.0)
262
+ # - `poll_interval_ms` [Integer] polling interval in milliseconds (default: 500)
263
+ # - `poll_batch_limit` [Integer] max documents per poll (default: 200)
264
+ class Listener < SubscriberMap
265
+ def initialize(adapter, event_loop)
266
+ super()
267
+ @adapter = adapter
268
+ @event_loop = event_loop
269
+ @running = true
270
+ @stream = nil
271
+ @resume_token = nil
272
+ @reconnect_attempts = 0
273
+
274
+ # Cache config values and collection to avoid accessing mocks from background thread
275
+ config = @adapter.server.config.cable
276
+ @reconnect_delay_base = config.fetch("reconnect_delay", 1.0).to_f
277
+ @max_reconnect_delay = config.fetch("max_reconnect_delay", 60.0).to_f
278
+ @poll_interval = config.fetch("poll_interval_ms", 500).to_i / 1000.0
279
+ @batch_limit = config.fetch("poll_batch_limit", 200).to_i
280
+ @collection = @adapter.collection
281
+
282
+ @thread = Thread.new { listen_loop }
283
+ @thread.name = "solid-cable-mongoid-#{Process.pid}" if @thread.respond_to?(:name=)
284
+ @thread.abort_on_exception = false
285
+ end
286
+
287
+ # Ensure callbacks fire on ActionCable's event loop for thread-safety.
288
+ def invoke_callback(*)
289
+ @event_loop.post { super }
290
+ end
291
+
292
+ # Graceful shutdown with configurable timeout.
293
+ def shutdown
294
+ @running = false
295
+ close_stream
296
+ return unless @thread&.alive?
297
+
298
+ @thread.join(5) || @thread.kill
299
+ end
300
+
301
+ private
302
+
303
+ # Calculate reconnect delay with exponential backoff.
304
+ #
305
+ # @return [Float] seconds to wait before retry
306
+ def reconnect_delay
307
+ [@reconnect_delay_base * (2**@reconnect_attempts), @max_reconnect_delay].min
308
+ end
309
+
310
+ # Polling interval in seconds.
311
+ #
312
+ # @return [Float]
313
+ attr_reader :poll_interval
314
+
315
+ # Max documents to fetch per poll.
316
+ #
317
+ # @return [Integer]
318
+ attr_reader :batch_limit
319
+
320
+ # Main listener loop that receives broadcasts from MongoDB.
321
+ #
322
+ # @return [void]
323
+ def listen_loop
324
+ pipeline = [{ "$match" => { "operationType" => "insert" } }]
325
+
326
+ while @running
327
+ begin
328
+ if change_stream_supported?
329
+ # Change Stream path (replica set / sharded)
330
+ opts = { max_await_time_ms: 1000 }
331
+ opts[:resume_after] = @resume_token if @resume_token
332
+
333
+ @stream = @collection.watch(pipeline, opts)
334
+ enum = @stream.to_enum
335
+
336
+ while @running && enum
337
+ doc = enum.try_next
338
+ next unless doc # nil when no event yet
339
+
340
+ handle_insert_doc(doc["fullDocument"] || {})
341
+ @resume_token = @stream.resume_token
342
+ @reconnect_attempts = 0 # Reset on successful iteration
343
+ end
344
+ else
345
+ # Standalone fallback: polling
346
+ poll_for_inserts
347
+ end
348
+ rescue Mongo::Error::OperationFailure => e
349
+ unless e.message.include?("operation exceeded time limit")
350
+ @adapter.logger.warn "SolidCableMongoid: operation error (#{e.class}): #{e.message}"
351
+ @reconnect_attempts += 1
352
+ end
353
+ sleep_with_backoff
354
+ rescue Mongo::Error => e
355
+ @adapter.logger.warn "SolidCableMongoid: connection error (#{e.class}): #{e.message}"
356
+ @reconnect_attempts += 1
357
+ sleep_with_backoff
358
+ rescue NoMethodError => e
359
+ # Null stream error
360
+ @adapter.logger.debug "SolidCableMongoid: stream unavailable (#{e.message})"
361
+ @reconnect_attempts += 1
362
+ sleep_with_backoff
363
+ rescue StandardError => e
364
+ backtrace = Array(e.backtrace).take(10).join("\n")
365
+ msg = "SolidCableMongoid: unexpected listener error (#{e.class}): #{e.message}\n#{backtrace}"
366
+ @adapter.logger.error msg
367
+ @reconnect_attempts += 1
368
+ sleep_with_backoff
369
+ ensure
370
+ close_stream
371
+ end
372
+ end
373
+ end
374
+
375
+ # Sleep with exponential backoff.
376
+ def sleep_with_backoff
377
+ delay = reconnect_delay
378
+ @adapter.logger.debug "SolidCableMongoid: retrying in #{delay}s (attempt #{@reconnect_attempts})"
379
+ sleep delay
380
+ end
381
+
382
+ # Close the active change stream.
383
+ #
384
+ # @return [void]
385
+ def close_stream
386
+ @stream&.close
387
+ rescue StandardError => e
388
+ @adapter.logger.debug "SolidCableMongoid: stream close warning (#{e.class}): #{e.message}"
389
+ ensure
390
+ @stream = nil
391
+ end
392
+
393
+ # Check if Change Streams are supported.
394
+ #
395
+ # @return [Boolean]
396
+ def change_stream_supported?
397
+ @adapter.replica_set_configured?
398
+ end
399
+
400
+ # Poll for newly inserted broadcast documents when Change Streams are unavailable.
401
+ #
402
+ # @return [void]
403
+ def poll_for_inserts
404
+ coll = @collection
405
+
406
+ # Start after current head to avoid replaying history
407
+ @last_seen_id ||= begin
408
+ last = coll.find({}, { projection: { _id: 1 } })
409
+ .sort({ _id: -1 })
410
+ .limit(1)
411
+ .first
412
+ last&.[]("_id")
413
+ end
414
+
415
+ interval = poll_interval
416
+
417
+ while @running && !change_stream_supported?
418
+ filter = @last_seen_id ? { "_id" => { "$gt" => @last_seen_id } } : {}
419
+ docs = coll.find(filter)
420
+ .sort({ _id: 1 })
421
+ .limit(batch_limit)
422
+ .to_a
423
+
424
+ docs.each do |doc|
425
+ handle_insert_doc(doc)
426
+ @last_seen_id = doc["_id"]
427
+ end
428
+
429
+ @reconnect_attempts = 0 # Reset on successful poll
430
+
431
+ # If full batch, loop immediately; otherwise sleep
432
+ sleep(interval) if docs.length < batch_limit
433
+ end
434
+ end
435
+
436
+ # Dispatch a broadcast document to local subscribers.
437
+ #
438
+ # @param full [Hash] the full document
439
+ # @return [void]
440
+ def handle_insert_doc(full)
441
+ channel = full["channel"].to_s
442
+ message = full["message"]
443
+ return unless @subscribers.key?(channel)
444
+
445
+ broadcast(channel, message)
446
+ rescue StandardError => e
447
+ @adapter.logger.error "SolidCableMongoid: failed to handle insert (#{e.class}): #{e.message}"
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidCableMongoidAdapter
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "solid_cable_mongoid_adapter/version"
4
+
5
+ module SolidCableMongoidAdapter
6
+ class Error < StandardError; end
7
+
8
+ class ReplicaSetRequiredError < Error
9
+ def initialize(msg = "MongoDB replica set is required for SolidCableMongoidAdapter")
10
+ super
11
+ end
12
+ end
13
+ end
14
+
15
+ # Auto-require the Action Cable adapter
16
+ require_relative "action_cable/subscription_adapter/solid_mongoid"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/solid_cable_mongoid_adapter/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "solid_cable_mongoid_adapter"
7
+ spec.version = SolidCableMongoidAdapter::VERSION
8
+ spec.authors = ["Sal Scotto"]
9
+ spec.email = ["sscotto@gmail.com"]
10
+
11
+ spec.summary = "MongoDB adapter for Action Cable using Mongoid"
12
+ spec.description = "A production-ready Action Cable subscription adapter that uses MongoDB (via Mongoid) " \
13
+ "as a durable, cross-process broadcast backend with Change Streams support"
14
+ spec.homepage = "https://github.com/washu/solid_cable_mongoid_adapter"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 2.7.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z 2>/dev/null`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ # Runtime dependencies
35
+ spec.add_dependency "actioncable", ">= 7.0", "< 9.0"
36
+ spec.add_dependency "mongo", ">= 2.18", "< 3.0"
37
+ spec.add_dependency "mongoid", ">= 7.0", "< 10.0"
38
+ end
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solid_cable_mongoid_adapter
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Sal Scotto
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-02-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actioncable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: mongo
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.18'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '3.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2.18'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '3.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: mongoid
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '7.0'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '10.0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '7.0'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '10.0'
73
+ description: A production-ready Action Cable subscription adapter that uses MongoDB
74
+ (via Mongoid) as a durable, cross-process broadcast backend with Change Streams
75
+ support
76
+ email:
77
+ - sscotto@gmail.com
78
+ executables: []
79
+ extensions: []
80
+ extra_rdoc_files: []
81
+ files:
82
+ - ".rspec"
83
+ - ".rubocop.yml"
84
+ - CHANGELOG.md
85
+ - CONTRIBUTING.md
86
+ - LICENSE.txt
87
+ - README.md
88
+ - Rakefile
89
+ - lib/action_cable/subscription_adapter/solid_mongoid.rb
90
+ - lib/solid_cable_mongoid_adapter.rb
91
+ - lib/solid_cable_mongoid_adapter/version.rb
92
+ - solid_cable_mongoid_adapter.gemspec
93
+ homepage: https://github.com/washu/solid_cable_mongoid_adapter
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ homepage_uri: https://github.com/washu/solid_cable_mongoid_adapter
98
+ source_code_uri: https://github.com/washu/solid_cable_mongoid_adapter
99
+ changelog_uri: https://github.com/washu/solid_cable_mongoid_adapter/blob/main/CHANGELOG.md
100
+ rubygems_mfa_required: 'true'
101
+ post_install_message:
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 2.7.0
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.5.22
117
+ signing_key:
118
+ specification_version: 4
119
+ summary: MongoDB adapter for Action Cable using Mongoid
120
+ test_files: []