jetstream_bridge 4.4.0 → 4.5.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 +4 -4
- data/CHANGELOG.md +92 -337
- data/README.md +1 -5
- data/docs/GETTING_STARTED.md +11 -7
- data/docs/PRODUCTION.md +51 -11
- data/docs/TESTING.md +24 -35
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +1 -1
- data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +1 -1
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +0 -4
- data/lib/generators/jetstream_bridge/install/install_generator.rb +5 -5
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +2 -2
- data/lib/jetstream_bridge/consumer/consumer.rb +34 -96
- data/lib/jetstream_bridge/consumer/health_monitor.rb +107 -0
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +51 -34
- data/lib/jetstream_bridge/core/config.rb +153 -46
- data/lib/jetstream_bridge/core/connection_manager.rb +513 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +9 -3
- data/lib/jetstream_bridge/core/health_checker.rb +184 -0
- data/lib/jetstream_bridge/core.rb +0 -2
- data/lib/jetstream_bridge/facade.rb +212 -0
- data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +110 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +87 -117
- data/lib/jetstream_bridge/rails/integration.rb +8 -5
- data/lib/jetstream_bridge/rails/railtie.rb +4 -3
- data/lib/jetstream_bridge/tasks/install.rake +0 -1
- data/lib/jetstream_bridge/topology/topology.rb +6 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +206 -297
- metadata +7 -5
- data/lib/jetstream_bridge/core/bridge_helpers.rb +0 -109
- data/lib/jetstream_bridge/core/connection.rb +0 -464
- data/lib/jetstream_bridge/core/connection_factory.rb +0 -100
data/docs/PRODUCTION.md
CHANGED
|
@@ -32,22 +32,26 @@ production:
|
|
|
32
32
|
### Sizing Guidelines
|
|
33
33
|
|
|
34
34
|
**Publishers (Web/API processes):**
|
|
35
|
+
|
|
35
36
|
- 1-2 connections per process (uses existing AR pool)
|
|
36
37
|
- Example: 4 Puma workers × 5 threads = 20 connections minimum
|
|
37
38
|
|
|
38
39
|
**Consumers:**
|
|
40
|
+
|
|
39
41
|
- Dedicated connections per consumer process
|
|
40
42
|
- Recommended: 2-5 connections per consumer
|
|
41
43
|
- Example: 3 consumer processes = 6-15 connections
|
|
42
44
|
|
|
43
45
|
**Total Formula:**
|
|
44
|
-
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
45
48
|
Total Connections = (Web Workers × Threads) + (Consumers × 3) + 10 buffer
|
|
46
49
|
```
|
|
47
50
|
|
|
48
51
|
### Example Calculation
|
|
49
52
|
|
|
50
53
|
For a typical production setup:
|
|
54
|
+
|
|
51
55
|
- 4 Puma workers × 5 threads = 20 connections
|
|
52
56
|
- 3 consumer processes × 3 connections = 9 connections
|
|
53
57
|
- 10 connection buffer = 10 connections
|
|
@@ -74,9 +78,9 @@ JetstreamBridge.configure do |config|
|
|
|
74
78
|
config.connect_retry_delay = 3 # Default: 2 seconds
|
|
75
79
|
|
|
76
80
|
# Required configuration
|
|
77
|
-
config.env = ENV.fetch("RAILS_ENV", "production")
|
|
78
81
|
config.app_name = ENV.fetch("APP_NAME", "myapp")
|
|
79
82
|
config.destination_app = ENV.fetch("DESTINATION_APP")
|
|
83
|
+
config.stream_name = ENV.fetch("STREAM_NAME", "myapp-stream")
|
|
80
84
|
|
|
81
85
|
# Enable reliability features
|
|
82
86
|
config.use_outbox = true
|
|
@@ -96,6 +100,31 @@ JetstreamBridge.configure do |config|
|
|
|
96
100
|
end
|
|
97
101
|
```
|
|
98
102
|
|
|
103
|
+
### Permissions and Inbox Prefix
|
|
104
|
+
|
|
105
|
+
If your NATS account restricts `_INBOX.>` subscriptions, set an allowed prefix:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
JetstreamBridge.configure do |config|
|
|
109
|
+
config.inbox_prefix = "$RPC"
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
For pre-provisioned streams and consumers:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
JetstreamBridge.configure do |config|
|
|
117
|
+
config.stream_name = "my-stream" # required
|
|
118
|
+
config.durable_name = "my-durable" # optional
|
|
119
|
+
config.disable_js_api = true # skip JetStream management APIs
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Minimum NATS permissions:
|
|
124
|
+
|
|
125
|
+
- **Publish**: `$JS.API.>`, `$JS.ACK.>`, source/destination subjects, DLQ subject
|
|
126
|
+
- **Subscribe**: `_INBOX.>` (or custom inbox_prefix), destination subject
|
|
127
|
+
|
|
99
128
|
---
|
|
100
129
|
|
|
101
130
|
## Consumer Tuning
|
|
@@ -135,9 +164,10 @@ end
|
|
|
135
164
|
### Memory Management
|
|
136
165
|
|
|
137
166
|
Long-running consumers automatically:
|
|
167
|
+
|
|
138
168
|
- Log health checks every 10 minutes (iterations, memory, uptime)
|
|
139
169
|
- Warn when memory exceeds 1GB
|
|
140
|
-
-
|
|
170
|
+
- Warn once when heap object counts grow large so you can profile/trigger GC in the host app
|
|
141
171
|
|
|
142
172
|
Monitor these logs to detect memory leaks early.
|
|
143
173
|
|
|
@@ -148,7 +178,7 @@ Monitor these logs to detect memory leaks early.
|
|
|
148
178
|
### Key Metrics to Track
|
|
149
179
|
|
|
150
180
|
| Metric | Description | Alert Threshold |
|
|
151
|
-
|
|
181
|
+
| -------- | ------------- | ----------------- |
|
|
152
182
|
| Consumer Lag | Pending messages in stream | > 1000 messages |
|
|
153
183
|
| DLQ Size | Messages in dead letter queue | > 100 messages |
|
|
154
184
|
| Connection Status | Health check failures | 2 consecutive failures |
|
|
@@ -164,7 +194,7 @@ Use the built-in health check for monitoring:
|
|
|
164
194
|
# config/routes.rb
|
|
165
195
|
Rails.application.routes.draw do
|
|
166
196
|
get '/health/jetstream', to: proc { |env|
|
|
167
|
-
health = JetstreamBridge.
|
|
197
|
+
health = JetstreamBridge.health
|
|
168
198
|
status = health[:healthy] ? 200 : 503
|
|
169
199
|
[status, { 'Content-Type' => 'application/json' }, [health.to_json]]
|
|
170
200
|
}
|
|
@@ -197,9 +227,10 @@ end
|
|
|
197
227
|
"destination_app": "worker",
|
|
198
228
|
"use_outbox": true,
|
|
199
229
|
"use_inbox": true,
|
|
200
|
-
"use_dlq": true
|
|
230
|
+
"use_dlq": true,
|
|
231
|
+
"disable_js_api": true
|
|
201
232
|
},
|
|
202
|
-
"version": "4.0
|
|
233
|
+
"version": "4.4.0"
|
|
203
234
|
}
|
|
204
235
|
```
|
|
205
236
|
|
|
@@ -253,6 +284,7 @@ end
|
|
|
253
284
|
### Subject Validation
|
|
254
285
|
|
|
255
286
|
JetStream Bridge validates subject components to prevent injection attacks. The following are automatically rejected:
|
|
287
|
+
|
|
256
288
|
- NATS wildcards (`.`, `*`, `>`)
|
|
257
289
|
- Spaces and control characters
|
|
258
290
|
- Components exceeding 255 characters
|
|
@@ -270,6 +302,7 @@ config.nats_urls = ENV.fetch("NATS_URLS")
|
|
|
270
302
|
```
|
|
271
303
|
|
|
272
304
|
Credentials in logs are automatically sanitized:
|
|
305
|
+
|
|
273
306
|
- `nats://user:pass@host:4222` → `nats://user:***@host:4222`
|
|
274
307
|
- `nats://token@host:4222` → `nats://***@host:4222`
|
|
275
308
|
|
|
@@ -345,6 +378,7 @@ spec:
|
|
|
345
378
|
### Health Probes
|
|
346
379
|
|
|
347
380
|
**Liveness Probe:** Checks if the consumer process is running
|
|
381
|
+
|
|
348
382
|
```yaml
|
|
349
383
|
livenessProbe:
|
|
350
384
|
exec:
|
|
@@ -354,6 +388,7 @@ livenessProbe:
|
|
|
354
388
|
```
|
|
355
389
|
|
|
356
390
|
**Readiness Probe:** Checks if NATS connection is healthy
|
|
391
|
+
|
|
357
392
|
```yaml
|
|
358
393
|
readinessProbe:
|
|
359
394
|
httpGet:
|
|
@@ -413,7 +448,7 @@ CREATE INDEX idx_inbox_stream_seq ON jetstream_inbox_events(stream, stream_seq);
|
|
|
413
448
|
CREATE INDEX idx_inbox_status ON jetstream_inbox_events(status);
|
|
414
449
|
```
|
|
415
450
|
|
|
416
|
-
|
|
451
|
+
1. **Partition large tables** (for high-volume applications):
|
|
417
452
|
|
|
418
453
|
```sql
|
|
419
454
|
-- Partition outbox by month
|
|
@@ -426,7 +461,7 @@ CREATE TABLE jetstream_outbox_events_2025_11
|
|
|
426
461
|
FOR VALUES FROM ('2025-11-01') TO ('2025-12-01');
|
|
427
462
|
```
|
|
428
463
|
|
|
429
|
-
|
|
464
|
+
1. **Archive old records** to prevent table bloat:
|
|
430
465
|
|
|
431
466
|
```ruby
|
|
432
467
|
# lib/tasks/jetstream_maintenance.rake
|
|
@@ -462,24 +497,28 @@ end
|
|
|
462
497
|
### Common Issues
|
|
463
498
|
|
|
464
499
|
**High Consumer Lag:**
|
|
500
|
+
|
|
465
501
|
- Scale up consumer instances
|
|
466
502
|
- Increase batch size
|
|
467
503
|
- Optimize handler processing time
|
|
468
504
|
- Check database connection pool
|
|
469
505
|
|
|
470
506
|
**Memory Leaks:**
|
|
507
|
+
|
|
471
508
|
- Monitor consumer health logs
|
|
472
509
|
- Enable memory profiling
|
|
473
510
|
- Check for circular references in handlers
|
|
474
511
|
- Restart consumers periodically (Kubernetes handles this)
|
|
475
512
|
|
|
476
513
|
**Connection Issues:**
|
|
514
|
+
|
|
477
515
|
- Verify NATS server is accessible
|
|
478
516
|
- Check firewall rules
|
|
479
517
|
- Validate TLS certificates
|
|
480
518
|
- Review connection retry settings
|
|
481
519
|
|
|
482
520
|
**DLQ Growing:**
|
|
521
|
+
|
|
483
522
|
- Investigate failed message patterns
|
|
484
523
|
- Fix bugs in message handlers
|
|
485
524
|
- Increase max_deliver for transient errors
|
|
@@ -499,5 +538,6 @@ end
|
|
|
499
538
|
## Support
|
|
500
539
|
|
|
501
540
|
For issues or questions:
|
|
502
|
-
|
|
503
|
-
-
|
|
541
|
+
|
|
542
|
+
- GitHub Issues: <https://github.com/attaradev/jetstream_bridge/issues>
|
|
543
|
+
- Documentation: <https://github.com/attaradev/jetstream_bridge>
|
data/docs/TESTING.md
CHANGED
|
@@ -28,31 +28,28 @@ RSpec.describe MyService do
|
|
|
28
28
|
include JetstreamBridge::TestHelpers::Matchers
|
|
29
29
|
|
|
30
30
|
before do
|
|
31
|
-
# Reset
|
|
32
|
-
JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
|
|
31
|
+
# Reset state
|
|
33
32
|
JetstreamBridge.reset!
|
|
34
33
|
|
|
35
34
|
# Enable test mode - automatically sets up mock NATS
|
|
36
35
|
JetstreamBridge::TestHelpers.enable_test_mode!
|
|
37
36
|
|
|
38
37
|
JetstreamBridge.configure do |config|
|
|
39
|
-
config.env = 'test'
|
|
40
38
|
config.app_name = 'my_app'
|
|
41
39
|
config.destination_app = 'worker'
|
|
40
|
+
config.stream_name = 'test-stream'
|
|
42
41
|
end
|
|
43
42
|
|
|
44
|
-
# Setup mock stream
|
|
43
|
+
# Setup mock stream
|
|
45
44
|
mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
|
|
46
45
|
mock_jts.add_stream(
|
|
47
|
-
name: 'test-
|
|
48
|
-
subjects: ['
|
|
46
|
+
name: 'test-stream',
|
|
47
|
+
subjects: ['my_app.sync.worker', 'worker.sync.my_app']
|
|
49
48
|
)
|
|
50
|
-
allow(JetstreamBridge::Topology).to receive(:ensure!)
|
|
51
49
|
end
|
|
52
50
|
|
|
53
51
|
after do
|
|
54
52
|
JetstreamBridge::TestHelpers.reset_test_mode!
|
|
55
|
-
JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
|
|
56
53
|
end
|
|
57
54
|
|
|
58
55
|
it 'publishes events through the full stack' do
|
|
@@ -266,27 +263,20 @@ end.to raise_error(NATS::JetStream::Error, 'consumer not found')
|
|
|
266
263
|
```ruby
|
|
267
264
|
before do
|
|
268
265
|
JetstreamBridge.reset!
|
|
269
|
-
|
|
270
|
-
mock_conn = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
|
|
271
|
-
mock_jts = mock_conn.jetstream
|
|
272
|
-
|
|
273
|
-
allow(NATS::IO::Client).to receive(:new).and_return(mock_conn)
|
|
274
|
-
allow(JetstreamBridge::Connection).to receive(:connect!).and_call_original
|
|
275
|
-
|
|
276
|
-
# Setup stream
|
|
277
|
-
mock_jts.add_stream(
|
|
278
|
-
name: 'test-jetstream-bridge-stream',
|
|
279
|
-
subjects: ['test.>']
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
# Allow topology check to succeed
|
|
283
|
-
allow(JetstreamBridge::Topology).to receive(:ensure!)
|
|
266
|
+
JetstreamBridge::TestHelpers.enable_test_mode!
|
|
284
267
|
|
|
285
268
|
JetstreamBridge.configure do |config|
|
|
286
|
-
config.env = 'test'
|
|
287
269
|
config.app_name = 'api'
|
|
288
270
|
config.destination_app = 'worker'
|
|
271
|
+
config.stream_name = 'test-stream'
|
|
289
272
|
end
|
|
273
|
+
|
|
274
|
+
# Setup mock stream
|
|
275
|
+
mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
|
|
276
|
+
mock_jts.add_stream(
|
|
277
|
+
name: 'test-stream',
|
|
278
|
+
subjects: ['api.sync.worker', 'worker.sync.api']
|
|
279
|
+
)
|
|
290
280
|
end
|
|
291
281
|
|
|
292
282
|
it 'publishes through JetstreamBridge' do
|
|
@@ -296,9 +286,9 @@ it 'publishes through JetstreamBridge' do
|
|
|
296
286
|
payload: { id: 1, name: 'Test' }
|
|
297
287
|
)
|
|
298
288
|
|
|
299
|
-
expect(result).to
|
|
289
|
+
expect(result.success?).to be true
|
|
300
290
|
expect(result.event_id).to be_present
|
|
301
|
-
expect(result.subject).to eq('
|
|
291
|
+
expect(result.subject).to eq('api.sync.worker')
|
|
302
292
|
|
|
303
293
|
# Verify in storage
|
|
304
294
|
storage = JetstreamBridge::TestHelpers.mock_storage
|
|
@@ -306,16 +296,15 @@ it 'publishes through JetstreamBridge' do
|
|
|
306
296
|
end
|
|
307
297
|
```
|
|
308
298
|
|
|
309
|
-
### Full
|
|
299
|
+
### Full Consumer Flow
|
|
310
300
|
|
|
311
301
|
```ruby
|
|
312
|
-
it 'consumes through JetstreamBridge' do
|
|
313
|
-
|
|
314
|
-
mock_jts = mock_conn.jetstream
|
|
302
|
+
it 'consumes messages through JetstreamBridge' do
|
|
303
|
+
mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
|
|
315
304
|
|
|
316
305
|
# Publish message to destination subject
|
|
317
306
|
mock_jts.publish(
|
|
318
|
-
'
|
|
307
|
+
'worker.sync.api',
|
|
319
308
|
Oj.dump({
|
|
320
309
|
'event_id' => 'event-1',
|
|
321
310
|
'schema_version' => 1,
|
|
@@ -338,9 +327,9 @@ it 'consumes through JetstreamBridge' do
|
|
|
338
327
|
|
|
339
328
|
# Mock subscription
|
|
340
329
|
subscription = mock_jts.pull_subscribe(
|
|
341
|
-
'
|
|
342
|
-
'
|
|
343
|
-
stream: 'test-
|
|
330
|
+
'worker.sync.api',
|
|
331
|
+
'api-workers',
|
|
332
|
+
stream: 'test-stream'
|
|
344
333
|
)
|
|
345
334
|
|
|
346
335
|
allow_any_instance_of(JetstreamBridge::SubscriptionManager)
|
|
@@ -391,7 +380,7 @@ storage.reset!
|
|
|
391
380
|
3. **Test both success and failure paths**: Use the mock to simulate errors
|
|
392
381
|
4. **Verify message content**: Check that envelopes are correctly formatted
|
|
393
382
|
5. **Test idempotency**: Verify duplicate detection and redelivery behavior
|
|
394
|
-
6. **
|
|
383
|
+
6. **Set stream_name**: Always configure `stream_name` in your tests
|
|
395
384
|
|
|
396
385
|
## Examples
|
|
397
386
|
|
|
@@ -4,7 +4,7 @@ require 'rails/generators'
|
|
|
4
4
|
|
|
5
5
|
module JetstreamBridge
|
|
6
6
|
module Generators
|
|
7
|
-
class HealthCheckGenerator < Rails::Generators::Base
|
|
7
|
+
class HealthCheckGenerator < ::Rails::Generators::Base
|
|
8
8
|
source_root File.expand_path('templates', __dir__)
|
|
9
9
|
desc 'Creates a health check endpoint for JetStream Bridge monitoring'
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ require 'rails/generators'
|
|
|
4
4
|
|
|
5
5
|
module JetstreamBridge
|
|
6
6
|
module Generators
|
|
7
|
-
class InitializerGenerator < Rails::Generators::Base
|
|
7
|
+
class InitializerGenerator < ::Rails::Generators::Base
|
|
8
8
|
source_root File.expand_path('templates', __dir__)
|
|
9
9
|
desc 'Creates config/initializers/jetstream_bridge.rb'
|
|
10
10
|
|
|
@@ -13,10 +13,6 @@ JetstreamBridge.configure do |config|
|
|
|
13
13
|
# NATS server URLs (comma-separated for cluster)
|
|
14
14
|
config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
|
|
15
15
|
|
|
16
|
-
# Environment identifier (e.g., 'development', 'production')
|
|
17
|
-
# Used in stream names and subject routing
|
|
18
|
-
config.env = ENV.fetch('NATS_ENV', Rails.env)
|
|
19
|
-
|
|
20
16
|
# Application name (used in subject routing)
|
|
21
17
|
config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
|
|
22
18
|
|
|
@@ -5,16 +5,16 @@ require 'rails/generators'
|
|
|
5
5
|
module JetstreamBridge
|
|
6
6
|
module Generators
|
|
7
7
|
# Install generator.
|
|
8
|
-
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
9
|
desc 'Creates JetstreamBridge initializer and migrations'
|
|
10
10
|
def create_initializer
|
|
11
|
-
Rails::Generators.invoke('jetstream_bridge:initializer', [], behavior: behavior,
|
|
12
|
-
|
|
11
|
+
::Rails::Generators.invoke('jetstream_bridge:initializer', [], behavior: behavior,
|
|
12
|
+
destination_root: destination_root)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def create_migrations
|
|
16
|
-
Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior,
|
|
17
|
-
|
|
16
|
+
::Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior,
|
|
17
|
+
destination_root: destination_root)
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -6,8 +6,8 @@ require 'rails/generators/active_record'
|
|
|
6
6
|
module JetstreamBridge
|
|
7
7
|
module Generators
|
|
8
8
|
# Migrations generator.
|
|
9
|
-
class MigrationsGenerator < Rails::Generators::Base
|
|
10
|
-
include Rails::Generators::Migration
|
|
9
|
+
class MigrationsGenerator < ::Rails::Generators::Base
|
|
10
|
+
include ::Rails::Generators::Migration
|
|
11
11
|
|
|
12
12
|
source_root File.expand_path('templates', __dir__)
|
|
13
13
|
desc 'Creates Inbox/Outbox migrations for JetstreamBridge'
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'oj'
|
|
4
4
|
require 'securerandom'
|
|
5
|
-
require_relative '../core/connection'
|
|
6
5
|
require_relative '../core/duration'
|
|
7
6
|
require_relative '../core/logging'
|
|
8
7
|
require_relative '../core/config'
|
|
@@ -10,6 +9,7 @@ require_relative '../core/model_utils'
|
|
|
10
9
|
require_relative 'message_processor'
|
|
11
10
|
require_relative 'subscription_manager'
|
|
12
11
|
require_relative 'inbox/inbox_processor'
|
|
12
|
+
require_relative 'health_monitor'
|
|
13
13
|
|
|
14
14
|
module JetstreamBridge
|
|
15
15
|
# Subscribes to destination subject and processes messages via a pull durable consumer.
|
|
@@ -48,6 +48,14 @@ module JetstreamBridge
|
|
|
48
48
|
IDLE_SLEEP_SECS = 0.05
|
|
49
49
|
# Maximum sleep duration during idle periods (seconds)
|
|
50
50
|
MAX_IDLE_BACKOFF_SECS = 1.0
|
|
51
|
+
# Maximum number of batches to drain during shutdown
|
|
52
|
+
MAX_DRAIN_BATCHES = 5
|
|
53
|
+
# Drain timeout per batch in seconds
|
|
54
|
+
DRAIN_BATCH_TIMEOUT = 1
|
|
55
|
+
# Minimum reconnect backoff in seconds
|
|
56
|
+
MIN_RECONNECT_BACKOFF = 0.1
|
|
57
|
+
# Maximum reconnect backoff in seconds
|
|
58
|
+
MAX_RECONNECT_BACKOFF = 30.0
|
|
51
59
|
|
|
52
60
|
# Alias middleware classes for easier access
|
|
53
61
|
MiddlewareChain = ConsumerMiddleware::MiddlewareChain
|
|
@@ -64,60 +72,51 @@ module JetstreamBridge
|
|
|
64
72
|
# @return [MiddlewareChain] Middleware chain for processing
|
|
65
73
|
attr_reader :middleware_chain
|
|
66
74
|
|
|
67
|
-
# Initialize a new Consumer instance.
|
|
75
|
+
# Initialize a new Consumer instance with dependency injection.
|
|
68
76
|
#
|
|
69
77
|
# @param handler [Proc, #call, nil] Message handler that processes events.
|
|
70
78
|
# Must respond to #call(event) or #call(event, subject, deliveries).
|
|
79
|
+
# @param connection [NATS::JetStream::JS] JetStream connection
|
|
80
|
+
# @param config [Config] Configuration instance
|
|
71
81
|
# @param durable_name [String, nil] Optional durable consumer name override.
|
|
72
|
-
# Defaults to config.durable_name.
|
|
73
82
|
# @param batch_size [Integer, nil] Number of messages to fetch per batch.
|
|
74
|
-
# Defaults to DEFAULT_BATCH_SIZE (25).
|
|
75
83
|
# @yield [event] Optional block as handler. Receives Models::Event object.
|
|
76
84
|
#
|
|
77
|
-
# @raise [ArgumentError] If
|
|
78
|
-
# @raise [ArgumentError] If destination_app not configured
|
|
79
|
-
# @raise [ConnectionError] If unable to connect to NATS
|
|
85
|
+
# @raise [ArgumentError] If required dependencies are missing
|
|
80
86
|
#
|
|
81
87
|
# @example With proc handler
|
|
82
88
|
# handler = ->(event) { puts "Received: #{event.type}" }
|
|
83
|
-
# consumer = JetstreamBridge::Consumer.new(handler)
|
|
89
|
+
# consumer = JetstreamBridge::Consumer.new(handler, connection: jts, config: config)
|
|
84
90
|
#
|
|
85
91
|
# @example With block
|
|
86
|
-
# consumer = JetstreamBridge::Consumer.new do |event|
|
|
92
|
+
# consumer = JetstreamBridge::Consumer.new(connection: jts, config: config) do |event|
|
|
87
93
|
# UserEventHandler.process(event)
|
|
88
94
|
# end
|
|
89
95
|
#
|
|
90
|
-
|
|
91
|
-
# consumer = JetstreamBridge::Consumer.new(
|
|
92
|
-
# handler,
|
|
93
|
-
# durable_name: "my-consumer",
|
|
94
|
-
# batch_size: 10
|
|
95
|
-
# )
|
|
96
|
-
#
|
|
97
|
-
def initialize(handler = nil, durable_name: nil, batch_size: nil, &block)
|
|
96
|
+
def initialize(handler = nil, connection:, config:, durable_name: nil, batch_size: nil, &block)
|
|
98
97
|
@handler = handler || block
|
|
99
98
|
raise ArgumentError, 'handler or block required' unless @handler
|
|
99
|
+
raise ArgumentError, 'connection is required' unless connection
|
|
100
|
+
raise ArgumentError, 'config is required' unless config
|
|
101
|
+
|
|
102
|
+
@jts = connection
|
|
103
|
+
@config = config
|
|
100
104
|
|
|
101
105
|
@batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
|
|
102
|
-
@durable = durable_name ||
|
|
106
|
+
@durable = durable_name || @config.durable_name
|
|
103
107
|
@idle_backoff = IDLE_SLEEP_SECS
|
|
104
108
|
@reconnect_attempts = 0
|
|
105
109
|
@running = true
|
|
106
110
|
@shutdown_requested = false
|
|
107
|
-
@start_time = Time.now
|
|
108
|
-
@iterations = 0
|
|
109
|
-
@last_health_check = Time.now
|
|
110
|
-
# Use existing connection (should already be established)
|
|
111
|
-
@jts = Connection.jetstream
|
|
112
|
-
raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
|
|
113
111
|
|
|
114
112
|
@middleware_chain = MiddlewareChain.new
|
|
113
|
+
@health_monitor = ConsumerHealthMonitor.new(@durable)
|
|
115
114
|
|
|
116
115
|
ensure_destination_app_configured!
|
|
117
116
|
|
|
118
|
-
@sub_mgr = SubscriptionManager.new(@jts, @durable,
|
|
117
|
+
@sub_mgr = SubscriptionManager.new(@jts, @durable, @config)
|
|
119
118
|
@processor = MessageProcessor.new(@jts, @handler, middleware_chain: @middleware_chain)
|
|
120
|
-
@inbox_proc = InboxProcessor.new(@processor) if
|
|
119
|
+
@inbox_proc = InboxProcessor.new(@processor) if @config.use_inbox
|
|
121
120
|
|
|
122
121
|
ensure_subscription!
|
|
123
122
|
setup_signal_handlers
|
|
@@ -193,17 +192,15 @@ module JetstreamBridge
|
|
|
193
192
|
#
|
|
194
193
|
def run!
|
|
195
194
|
Logging.info(
|
|
196
|
-
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{
|
|
195
|
+
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{@config.destination_subject})…",
|
|
197
196
|
tag: 'JetstreamBridge::Consumer'
|
|
198
197
|
)
|
|
199
198
|
while @running
|
|
200
199
|
processed = process_batch
|
|
201
200
|
idle_sleep(processed)
|
|
202
201
|
|
|
203
|
-
@
|
|
204
|
-
|
|
205
|
-
# Periodic health checks every 10 minutes (600 seconds)
|
|
206
|
-
perform_health_check_if_due
|
|
202
|
+
@health_monitor.increment_iterations
|
|
203
|
+
@health_monitor.check_health_if_due
|
|
207
204
|
end
|
|
208
205
|
|
|
209
206
|
# Drain in-flight messages before exiting
|
|
@@ -245,13 +242,13 @@ module JetstreamBridge
|
|
|
245
242
|
private
|
|
246
243
|
|
|
247
244
|
def ensure_destination_app_configured!
|
|
248
|
-
return unless
|
|
245
|
+
return unless @config.destination_app.to_s.empty?
|
|
249
246
|
|
|
250
247
|
raise ArgumentError, 'destination_app must be configured'
|
|
251
248
|
end
|
|
252
249
|
|
|
253
250
|
def ensure_subscription!
|
|
254
|
-
@sub_mgr.ensure_consumer!
|
|
251
|
+
@sub_mgr.ensure_consumer! unless @config.disable_js_api
|
|
255
252
|
@psub = @sub_mgr.subscribe!
|
|
256
253
|
end
|
|
257
254
|
|
|
@@ -314,10 +311,8 @@ module JetstreamBridge
|
|
|
314
311
|
end
|
|
315
312
|
|
|
316
313
|
def calculate_reconnect_backoff(attempt)
|
|
317
|
-
# Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to
|
|
318
|
-
|
|
319
|
-
max_delay = 30.0
|
|
320
|
-
[base_delay * (2**(attempt - 1)), max_delay].min
|
|
314
|
+
# Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to max
|
|
315
|
+
[MIN_RECONNECT_BACKOFF * (2**(attempt - 1)), MAX_RECONNECT_BACKOFF].min
|
|
321
316
|
end
|
|
322
317
|
|
|
323
318
|
def recoverable_consumer_error?(error)
|
|
@@ -357,70 +352,13 @@ module JetstreamBridge
|
|
|
357
352
|
Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
358
353
|
end
|
|
359
354
|
|
|
360
|
-
def perform_health_check_if_due
|
|
361
|
-
now = Time.now
|
|
362
|
-
time_since_check = now - @last_health_check
|
|
363
|
-
|
|
364
|
-
return unless time_since_check >= 600 # 10 minutes
|
|
365
|
-
|
|
366
|
-
@last_health_check = now
|
|
367
|
-
uptime = now - @start_time
|
|
368
|
-
memory_mb = memory_usage_mb
|
|
369
|
-
|
|
370
|
-
Logging.info(
|
|
371
|
-
"Consumer health: iterations=#{@iterations}, " \
|
|
372
|
-
"memory=#{memory_mb}MB, uptime=#{uptime.round}s",
|
|
373
|
-
tag: 'JetstreamBridge::Consumer'
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
# Warn if memory usage is high (over 1GB)
|
|
377
|
-
if memory_mb > 1000
|
|
378
|
-
Logging.warn(
|
|
379
|
-
"High memory usage detected: #{memory_mb}MB",
|
|
380
|
-
tag: 'JetstreamBridge::Consumer'
|
|
381
|
-
)
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# Suggest GC if heap is growing significantly
|
|
385
|
-
suggest_gc_if_needed
|
|
386
|
-
rescue StandardError => e
|
|
387
|
-
Logging.debug(
|
|
388
|
-
"Health check failed: #{e.class} #{e.message}",
|
|
389
|
-
tag: 'JetstreamBridge::Consumer'
|
|
390
|
-
)
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def memory_usage_mb
|
|
394
|
-
# Get memory usage from OS (works on Linux/macOS)
|
|
395
|
-
rss_kb = `ps -o rss= -p #{Process.pid}`.to_i
|
|
396
|
-
rss_kb / 1024.0
|
|
397
|
-
rescue StandardError
|
|
398
|
-
0.0
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
def suggest_gc_if_needed
|
|
402
|
-
# Suggest GC if heap has many live slots (Ruby-specific optimization)
|
|
403
|
-
return unless defined?(GC) && GC.respond_to?(:stat)
|
|
404
|
-
|
|
405
|
-
stats = GC.stat
|
|
406
|
-
heap_live_slots = stats[:heap_live_slots] || stats['heap_live_slots'] || 0
|
|
407
|
-
|
|
408
|
-
# Suggest GC if we have over 100k live objects
|
|
409
|
-
GC.start if heap_live_slots > 100_000
|
|
410
|
-
rescue StandardError => e
|
|
411
|
-
Logging.debug(
|
|
412
|
-
"GC check failed: #{e.class} #{e.message}",
|
|
413
|
-
tag: 'JetstreamBridge::Consumer'
|
|
414
|
-
)
|
|
415
|
-
end
|
|
416
|
-
|
|
417
355
|
def drain_inflight_messages
|
|
418
356
|
return unless @psub
|
|
419
357
|
|
|
420
358
|
Logging.info('Draining in-flight messages...', tag: 'JetstreamBridge::Consumer')
|
|
421
359
|
# Process any pending messages with a short timeout
|
|
422
|
-
|
|
423
|
-
msgs = @psub.fetch(@batch_size, timeout:
|
|
360
|
+
MAX_DRAIN_BATCHES.times do
|
|
361
|
+
msgs = @psub.fetch(@batch_size, timeout: DRAIN_BATCH_TIMEOUT)
|
|
424
362
|
break if msgs.nil? || msgs.empty?
|
|
425
363
|
|
|
426
364
|
msgs.each { |m| process_one(m) }
|