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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +45 -0
- data/CHANGELOG.md +46 -0
- data/CONTRIBUTING.md +85 -0
- data/LICENSE.txt +21 -0
- data/README.md +317 -0
- data/Rakefile +10 -0
- data/lib/action_cable/subscription_adapter/solid_mongoid.rb +452 -0
- data/lib/solid_cable_mongoid_adapter/version.rb +5 -0
- data/lib/solid_cable_mongoid_adapter.rb +16 -0
- data/solid_cable_mongoid_adapter.gemspec +38 -0
- metadata +120 -0
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
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
|
+
[](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,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,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: []
|