jetstream_bridge 7.0.0 → 7.0.2
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/docs/ARCHITECTURE.md +60 -170
- data/docs/PRODUCTION.md +9 -2
- data/docs/RESTRICTED_PERMISSIONS.md +7 -14
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +11 -1
- data/lib/jetstream_bridge/config_helpers/lifecycle.rb +34 -0
- data/lib/jetstream_bridge/config_helpers.rb +25 -29
- data/lib/jetstream_bridge/consumer/consumer.rb +81 -9
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +33 -9
- data/lib/jetstream_bridge/core/config.rb +31 -0
- data/lib/jetstream_bridge/core/connection.rb +95 -29
- data/lib/jetstream_bridge/core/consumer_mode_resolver.rb +64 -0
- data/lib/jetstream_bridge/provisioner.rb +41 -2
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b438f568ca21ece1da4e01f0fcea10ab2a2ec893e56a988a54808108f3e46830
|
|
4
|
+
data.tar.gz: d7733a1316e720b74d7ac3e887e48aeb49923a7aa555612249ee9ca90e0e3c65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 197a224969a88b1a3df0652a7c42f1597e9b31068574ae4706f8d54710acc5597a1b45ef9deef164ad69c1b41155e7086d2b79d9e794dafd09fb1039247d55a9
|
|
7
|
+
data.tar.gz: 224e1a0a69233c419a296fc0184de56a414b2a409f7d6a17bf82a831f746193ee7f87f46bd712dc24f23b1f707494978979594ff84cddd451a0810f3a7a3083b
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -29,51 +29,27 @@ JetStream Bridge provides reliable, production-ready message passing between Rub
|
|
|
29
29
|
|
|
30
30
|
### Architecture Diagram
|
|
31
31
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
│ Stream: "my-stream" │
|
|
54
|
-
│ Subjects: │
|
|
55
|
-
│ - api.sync.worker │
|
|
56
|
-
│ - worker.sync.api │
|
|
57
|
-
│ - api.sync.dlq │
|
|
58
|
-
│ - worker.sync.dlq │
|
|
59
|
-
│ │
|
|
60
|
-
│ Consumers: │
|
|
61
|
-
│ - api-workers │
|
|
62
|
-
│ - worker-workers │
|
|
63
|
-
└──────────────────────┘
|
|
64
|
-
│ │
|
|
65
|
-
┌──────────────┘ └────────────┐
|
|
66
|
-
│ │
|
|
67
|
-
│ Subscribe to: │ Subscribe to:
|
|
68
|
-
│ worker.sync.api │ api.sync.worker
|
|
69
|
-
│ │
|
|
70
|
-
┌────────▼────────┐ ┌────────▼────────┐
|
|
71
|
-
│ Consumer │ │ Consumer │
|
|
72
|
-
│ (Optional │ │ (Optional │
|
|
73
|
-
│ Inbox) │ │ Inbox) │
|
|
74
|
-
│ │ │ │
|
|
75
|
-
│ DLQ Handler │ │ DLQ Handler │
|
|
76
|
-
└─────────────────┘ └─────────────────┘
|
|
32
|
+
```mermaid
|
|
33
|
+
flowchart LR
|
|
34
|
+
subgraph AppA["Application \"api\""]
|
|
35
|
+
api_pub["Publisher\n(Optional Outbox)"]
|
|
36
|
+
api_cons["Consumer\n(Optional Inbox)\nDLQ Handler"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
subgraph Stream["NATS JetStream\nStream: \"my-stream\""]
|
|
40
|
+
subjects["Subjects:\n- api.sync.worker\n- worker.sync.api\n- api.sync.dlq\n- worker.sync.dlq"]
|
|
41
|
+
consumers["Consumers:\n- api-workers\n- worker-workers"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
subgraph AppB["Application \"worker\""]
|
|
45
|
+
worker_pub["Publisher\n(Optional Outbox)"]
|
|
46
|
+
worker_cons["Consumer\n(Optional Inbox)\nDLQ Handler"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
api_pub -- "Publish: api.sync.worker" --> Stream
|
|
50
|
+
worker_pub -- "Publish: worker.sync.api" --> Stream
|
|
51
|
+
Stream -- "Subscribe: worker.sync.api" --> api_cons
|
|
52
|
+
Stream -- "Subscribe: api.sync.worker" --> worker_cons
|
|
77
53
|
```
|
|
78
54
|
|
|
79
55
|
---
|
|
@@ -216,19 +192,21 @@ Orchestrates stream and consumer provisioning:
|
|
|
216
192
|
|
|
217
193
|
**One stream per application pair** (or shared stream for multiple apps):
|
|
218
194
|
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
195
|
+
```mermaid
|
|
196
|
+
flowchart TD
|
|
197
|
+
stream["Stream: jetstream-bridge-stream"]
|
|
198
|
+
stream --> retention["Retention: workqueue"]
|
|
199
|
+
stream --> storage["Storage: file"]
|
|
200
|
+
|
|
201
|
+
stream --> subjects["Subjects"]
|
|
202
|
+
subjects --> subj1["api.sync.worker\n(api publishes → worker consumes)"]
|
|
203
|
+
subjects --> subj2["worker.sync.api\n(worker publishes → api consumes)"]
|
|
204
|
+
subjects --> subj3["api.sync.dlq\n(api dead letter queue)"]
|
|
205
|
+
subjects --> subj4["worker.sync.dlq\n(worker dead letter queue)"]
|
|
206
|
+
|
|
207
|
+
stream --> consumers["Consumers"]
|
|
208
|
+
consumers --> c1["api-workers\n(filters worker.sync.api)"]
|
|
209
|
+
consumers --> c2["worker-workers\n(filters api.sync.worker)"]
|
|
232
210
|
```
|
|
233
211
|
|
|
234
212
|
### Subject Pattern
|
|
@@ -315,120 +293,31 @@ Overlap detection ensures messages route to exactly one stream.
|
|
|
315
293
|
|
|
316
294
|
### Publishing Flow
|
|
317
295
|
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
│ - Extract resource_id from payload │
|
|
328
|
-
│ - Add timestamps, producer, schema_version │
|
|
329
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
330
|
-
│
|
|
331
|
-
▼
|
|
332
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
333
|
-
│ 3. [OPTIONAL] Outbox pattern │
|
|
334
|
-
│ - OutboxRepository.record_publish_attempt() │
|
|
335
|
-
│ - State: "publishing" │
|
|
336
|
-
│ - Database transaction commits │
|
|
337
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
338
|
-
│
|
|
339
|
-
▼
|
|
340
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
341
|
-
│ 4. Publish to NATS JetStream │
|
|
342
|
-
│ - Subject: {app_name}.sync.{destination_app} │
|
|
343
|
-
│ - Header: nats-msg-id = event_id (deduplication) │
|
|
344
|
-
│ - Retry with exponential backoff on transient errors │
|
|
345
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
346
|
-
│
|
|
347
|
-
▼
|
|
348
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
349
|
-
│ 5. [OPTIONAL] Outbox update │
|
|
350
|
-
│ - Success: OutboxRepository.record_publish_success() │
|
|
351
|
-
│ - Failure: OutboxRepository.record_publish_failure() │
|
|
352
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
353
|
-
│
|
|
354
|
-
▼
|
|
355
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
356
|
-
│ 6. Return PublishResult │
|
|
357
|
-
│ - success: true/false │
|
|
358
|
-
│ - event_id: UUID │
|
|
359
|
-
│ - duplicate: true/false (if seen before) │
|
|
360
|
-
└──────────────────────────────────────────────────────────────┘
|
|
296
|
+
```mermaid
|
|
297
|
+
flowchart TD
|
|
298
|
+
p1["1. Application calls JetstreamBridge.publish(...)"]
|
|
299
|
+
p2["2. Publisher builds envelope\n- Generate event_id (UUID)\n- Extract resource_id from payload\n- Add timestamps, producer, schema_version"]
|
|
300
|
+
p3["3. Optional Outbox pattern\n- record_publish_attempt\n- State: \"publishing\"\n- Database transaction commits"]
|
|
301
|
+
p4["4. Publish to NATS JetStream\n- Subject: {app_name}.sync.{destination_app}\n- Header: nats-msg-id = event_id\n- Retry with exponential backoff"]
|
|
302
|
+
p5["5. Optional Outbox update\n- record_publish_success\n- record_publish_failure"]
|
|
303
|
+
p6["6. Return PublishResult\n- success flag\n- event_id\n- duplicate?"]
|
|
304
|
+
p1 --> p2 --> p3 --> p4 --> p5 --> p6
|
|
361
305
|
```
|
|
362
306
|
|
|
363
307
|
### Consuming Flow
|
|
364
308
|
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
│
|
|
378
|
-
▼
|
|
379
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
380
|
-
│ 3. Subscribe to consumer │
|
|
381
|
-
│ - Pull mode: $JS.API.CONSUMER.MSG.NEXT.{stream}.{durable}│
|
|
382
|
-
│ - Push mode: {delivery_subject} │
|
|
383
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
384
|
-
│
|
|
385
|
-
▼
|
|
386
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
387
|
-
│ 4. Consumer.run! starts main loop │
|
|
388
|
-
│ - Fetch batch of messages │
|
|
389
|
-
│ - Process each message sequentially │
|
|
390
|
-
│ - Idle backoff when no messages (0.05s → 1.0s) │
|
|
391
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
392
|
-
│
|
|
393
|
-
▼
|
|
394
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
395
|
-
│ 5. [OPTIONAL] Inbox deduplication check │
|
|
396
|
-
│ - InboxRepository.find_or_build(event_id) │
|
|
397
|
-
│ - If already processed → skip and ack │
|
|
398
|
-
│ - If new → InboxRepository.persist_pre() │
|
|
399
|
-
│ - State: "processing" │
|
|
400
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
401
|
-
│
|
|
402
|
-
▼
|
|
403
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
404
|
-
│ 6. MessageProcessor.handle_message() │
|
|
405
|
-
│ - Parse JSON envelope → Event object │
|
|
406
|
-
│ - Run middleware chain │
|
|
407
|
-
│ - Call user handler block │
|
|
408
|
-
│ - Return ActionResult (:ack or :nak) │
|
|
409
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
410
|
-
│
|
|
411
|
-
▼
|
|
412
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
413
|
-
│ 7. Error handling │
|
|
414
|
-
│ - Unrecoverable (ArgumentError, TypeError) → DLQ + ack │
|
|
415
|
-
│ - Recoverable (StandardError) → nak with backoff │
|
|
416
|
-
│ - Malformed JSON → DLQ + ack │
|
|
417
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
418
|
-
│
|
|
419
|
-
▼
|
|
420
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
421
|
-
│ 8. [OPTIONAL] Inbox update │
|
|
422
|
-
│ - Success: InboxRepository.persist_post() │
|
|
423
|
-
│ - Failure: InboxRepository.persist_failure() │
|
|
424
|
-
└──────────────────────┬───────────────────────────────────────┘
|
|
425
|
-
│
|
|
426
|
-
▼
|
|
427
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
428
|
-
│ 9. Acknowledge message │
|
|
429
|
-
│ - :ack → msg.ack (removes from stream) │
|
|
430
|
-
│ - :nak → msg.nak(delay: backoff) (requeue for retry) │
|
|
431
|
-
└──────────────────────────────────────────────────────────────┘
|
|
309
|
+
```mermaid
|
|
310
|
+
flowchart TD
|
|
311
|
+
c1["1. Application creates Consumer.new { |event| ... }"]
|
|
312
|
+
c2["2. SubscriptionManager ensures durable consumer\n- {app_name}-workers\n- Filter: {destination_app}.sync.{app_name}\n- Create if missing (idempotent)"]
|
|
313
|
+
c3["3. Subscribe to consumer\n- Pull: $JS.API.CONSUMER.MSG.NEXT.{stream}.{durable}\n- Push: {delivery_subject}"]
|
|
314
|
+
c4["4. Consumer.run! main loop\n- Fetch batch of messages\n- Process sequentially\n- Idle backoff 0.05s → 1.0s"]
|
|
315
|
+
c5["5. Optional inbox deduplication\n- find_or_build(event_id)\n- Skip if processed\n- persist_pre marks processing"]
|
|
316
|
+
c6["6. MessageProcessor.handle_message\n- Parse envelope → Event\n- Run middleware chain\n- Call user handler\n- Return :ack or :nak"]
|
|
317
|
+
c7["7. Error handling\n- Unrecoverable → DLQ + ack\n- Recoverable → nak with backoff\n- Malformed JSON → DLQ + ack"]
|
|
318
|
+
c8["8. Optional inbox update\n- persist_post\n- persist_failure"]
|
|
319
|
+
c9["9. Acknowledge message\n- :ack → msg.ack\n- :nak → msg.nak(delay: backoff)"]
|
|
320
|
+
c1 --> c2 --> c3 --> c4 --> c5 --> c6 --> c7 --> c8 --> c9
|
|
432
321
|
```
|
|
433
322
|
|
|
434
323
|
---
|
|
@@ -1067,8 +956,9 @@ end
|
|
|
1067
956
|
|
|
1068
957
|
**Exponential backoff when no messages:**
|
|
1069
958
|
|
|
1070
|
-
```
|
|
1071
|
-
|
|
959
|
+
```mermaid
|
|
960
|
+
flowchart LR
|
|
961
|
+
t1["0.05s"] --> t2["0.1s"] --> t3["0.2s"] --> t4["0.4s"] --> t5["0.8s"] --> t6["1.0s (max)"]
|
|
1072
962
|
```
|
|
1073
963
|
|
|
1074
964
|
**Benefit:** Reduces CPU and network usage during idle periods
|
data/docs/PRODUCTION.md
CHANGED
|
@@ -44,8 +44,15 @@ production:
|
|
|
44
44
|
|
|
45
45
|
**Total Formula:**
|
|
46
46
|
|
|
47
|
-
```
|
|
48
|
-
|
|
47
|
+
```mermaid
|
|
48
|
+
flowchart LR
|
|
49
|
+
ww["Web workers × threads"]
|
|
50
|
+
cons["Consumers × 3"]
|
|
51
|
+
buffer["+ 10 buffer"]
|
|
52
|
+
ww --> sum
|
|
53
|
+
cons --> sum
|
|
54
|
+
buffer --> sum
|
|
55
|
+
sum["Total connections"]
|
|
49
56
|
```
|
|
50
57
|
|
|
51
58
|
### Example Calculation
|
|
@@ -410,24 +410,17 @@ end
|
|
|
410
410
|
|
|
411
411
|
### Deploy the Updated Gem
|
|
412
412
|
|
|
413
|
-
|
|
413
|
+
Use the published gem in your application (no local path):
|
|
414
414
|
|
|
415
|
-
```
|
|
416
|
-
#
|
|
417
|
-
gem
|
|
418
|
-
|
|
419
|
-
# Install locally for testing
|
|
420
|
-
gem install ./jetstream_bridge-7.0.0.gem
|
|
421
|
-
|
|
422
|
-
# Or update in your application's Gemfile.lock
|
|
423
|
-
bundle update jetstream_bridge
|
|
415
|
+
```ruby
|
|
416
|
+
# Gemfile
|
|
417
|
+
gem "jetstream_bridge", "~> 7.0"
|
|
424
418
|
```
|
|
425
419
|
|
|
426
|
-
|
|
420
|
+
Then update:
|
|
427
421
|
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
gem 'jetstream_bridge', path: '/path/to/local/jetstream_bridge'
|
|
422
|
+
```bash
|
|
423
|
+
bundle update jetstream_bridge
|
|
431
424
|
```
|
|
432
425
|
|
|
433
426
|
### Restart the Service
|
|
@@ -28,13 +28,23 @@ module JetstreamBridge
|
|
|
28
28
|
|
|
29
29
|
# -- Rails::Generators::Migration plumbing --
|
|
30
30
|
def self.next_migration_number(dirname)
|
|
31
|
-
if
|
|
31
|
+
if timestamped_migrations?
|
|
32
32
|
Time.now.utc.strftime('%Y%m%d%H%M%S')
|
|
33
33
|
else
|
|
34
34
|
format('%.3d', current_migration_number(dirname) + 1)
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def self.timestamped_migrations?
|
|
39
|
+
if ActiveRecord::Base.respond_to?(:timestamped_migrations)
|
|
40
|
+
ActiveRecord::Base.timestamped_migrations
|
|
41
|
+
elsif ActiveRecord::Migration.respond_to?(:timestamped_migrations)
|
|
42
|
+
ActiveRecord::Migration.timestamped_migrations
|
|
43
|
+
else
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
38
48
|
private
|
|
39
49
|
|
|
40
50
|
def migration_exists?(dirname, file_name)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module ConfigHelpers
|
|
5
|
+
module Lifecycle
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def setup(logger: nil, rails_app: nil)
|
|
9
|
+
app = rails_app
|
|
10
|
+
app ||= Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
11
|
+
|
|
12
|
+
# Gracefully no-op when Rails isn't available (e.g., non-Rails runtimes or early boot)
|
|
13
|
+
return unless app
|
|
14
|
+
|
|
15
|
+
effective_logger = logger || default_rails_logger(app)
|
|
16
|
+
|
|
17
|
+
app.config.after_initialize do
|
|
18
|
+
JetstreamBridge.startup!
|
|
19
|
+
effective_logger&.info('JetStream Bridge connected successfully')
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
effective_logger&.error("Failed to connect to JetStream: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Kernel.at_exit { JetstreamBridge.shutdown! }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def default_rails_logger(app = nil)
|
|
28
|
+
return app.logger if app.respond_to?(:logger)
|
|
29
|
+
|
|
30
|
+
defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'core/logging'
|
|
4
4
|
require_relative 'core/config'
|
|
5
|
+
require_relative 'core/consumer_mode_resolver'
|
|
6
|
+
require_relative 'config_helpers/lifecycle'
|
|
5
7
|
|
|
6
8
|
module JetstreamBridge
|
|
7
9
|
# Convenience helpers to keep example configuration lean and consistent.
|
|
@@ -52,28 +54,8 @@ module JetstreamBridge
|
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# Wire JetstreamBridge lifecycle into Rails boot/shutdown.
|
|
55
|
-
#
|
|
56
|
-
# Safe to call multiple times; startup! is idempotent.
|
|
57
|
-
#
|
|
58
|
-
# @param logger [Logger,nil] Logger to use for lifecycle messages
|
|
59
|
-
# @return [void]
|
|
60
57
|
def setup_rails_lifecycle(logger: nil, rails_app: nil)
|
|
61
|
-
|
|
62
|
-
app ||= Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
63
|
-
|
|
64
|
-
# Gracefully no-op when Rails isn't available (e.g., non-Rails runtimes or early boot)
|
|
65
|
-
return unless app
|
|
66
|
-
|
|
67
|
-
effective_logger = logger || default_rails_logger(app)
|
|
68
|
-
|
|
69
|
-
app.config.after_initialize do
|
|
70
|
-
JetstreamBridge.startup!
|
|
71
|
-
effective_logger&.info('JetStream Bridge connected successfully')
|
|
72
|
-
rescue StandardError => e
|
|
73
|
-
effective_logger&.error("Failed to connect to JetStream: #{e.message}")
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
Kernel.at_exit { JetstreamBridge.shutdown! }
|
|
58
|
+
Lifecycle.setup(logger: logger, rails_app: rails_app)
|
|
77
59
|
end
|
|
78
60
|
|
|
79
61
|
def restrictive?(mode)
|
|
@@ -90,10 +72,31 @@ module JetstreamBridge
|
|
|
90
72
|
config.auto_provision = !restrictive?(mode)
|
|
91
73
|
config.use_outbox = use_outbox
|
|
92
74
|
config.use_inbox = use_inbox
|
|
93
|
-
config.consumer_mode =
|
|
75
|
+
config.consumer_mode = resolve_consumer_mode(app_name, overrides)
|
|
94
76
|
end
|
|
95
77
|
private_class_method :apply_base_settings
|
|
96
78
|
|
|
79
|
+
# Resolve consumer_mode with priority:
|
|
80
|
+
# 1) explicit override passed to configure_bidirectional
|
|
81
|
+
# 2) per-app env via CONSUMER_MODES map or CONSUMER_MODE_<APP_NAME>
|
|
82
|
+
# 3) shared env CONSUMER_MODE
|
|
83
|
+
# 4) existing config value or :pull
|
|
84
|
+
def resolve_consumer_mode(app_name, overrides)
|
|
85
|
+
explicit = overrides[:consumer_mode] if overrides.key?(:consumer_mode)
|
|
86
|
+
config_default = begin
|
|
87
|
+
JetstreamBridge.config&.consumer_mode
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
ConsumerModeResolver.resolve(
|
|
93
|
+
app_name: app_name,
|
|
94
|
+
override: explicit,
|
|
95
|
+
fallback: config_default || :pull
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
private_class_method :resolve_consumer_mode
|
|
99
|
+
|
|
97
100
|
def apply_reliability_defaults(config, overrides)
|
|
98
101
|
config.max_deliver = overrides.fetch(:max_deliver, DEFAULT_MAX_DELIVER)
|
|
99
102
|
config.ack_wait = overrides.fetch(:ack_wait, DEFAULT_ACK_WAIT)
|
|
@@ -111,12 +114,5 @@ module JetstreamBridge
|
|
|
111
114
|
end
|
|
112
115
|
end
|
|
113
116
|
private_class_method :apply_overrides
|
|
114
|
-
|
|
115
|
-
def default_rails_logger(app = nil)
|
|
116
|
-
return app.logger if app.respond_to?(:logger)
|
|
117
|
-
|
|
118
|
-
defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
119
|
-
end
|
|
120
|
-
private_class_method :default_rails_logger
|
|
121
117
|
end
|
|
122
118
|
end
|
|
@@ -58,6 +58,83 @@ module JetstreamBridge
|
|
|
58
58
|
TracingMiddleware = ConsumerMiddleware::TracingMiddleware
|
|
59
59
|
TimeoutMiddleware = ConsumerMiddleware::TimeoutMiddleware
|
|
60
60
|
|
|
61
|
+
class << self
|
|
62
|
+
def register_consumer_for_signals(consumer)
|
|
63
|
+
signal_registry_mutex.synchronize do
|
|
64
|
+
signal_consumers << consumer
|
|
65
|
+
install_signal_handlers_once
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unregister_consumer_for_signals(consumer)
|
|
70
|
+
signal_registry_mutex.synchronize { signal_consumers.delete(consumer) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_signal_handlers!
|
|
74
|
+
signal_registry_mutex.synchronize { signal_consumers.clear }
|
|
75
|
+
@signal_handlers_installed = false
|
|
76
|
+
@previous_signal_handlers = {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def signal_consumers
|
|
82
|
+
@signal_consumers ||= []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def signal_registry_mutex
|
|
86
|
+
@signal_registry_mutex ||= Mutex.new
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def install_signal_handlers_once
|
|
90
|
+
return if @signal_handlers_installed
|
|
91
|
+
|
|
92
|
+
%w[INT TERM].each { |sig| install_signal_handler(sig) }
|
|
93
|
+
@signal_handlers_installed = true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def install_signal_handler(sig)
|
|
97
|
+
previous = nil
|
|
98
|
+
handler = nil
|
|
99
|
+
handler = proc do
|
|
100
|
+
broadcast_signal(sig)
|
|
101
|
+
invoke_previous_handler(previous, sig, handler)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
# Trap contexts must stay minimal; swallow any unexpected errors
|
|
104
|
+
end
|
|
105
|
+
previous = Signal.trap(sig, &handler)
|
|
106
|
+
previous_signal_handlers[sig] = previous
|
|
107
|
+
rescue ArgumentError => e
|
|
108
|
+
Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def broadcast_signal(sig)
|
|
112
|
+
consumers = nil
|
|
113
|
+
signal_registry_mutex.synchronize { consumers = signal_consumers.dup }
|
|
114
|
+
consumers.each do |consumer|
|
|
115
|
+
next unless consumer.respond_to?(:lifecycle_state)
|
|
116
|
+
|
|
117
|
+
consumer.lifecycle_state.signal!(sig)
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError
|
|
120
|
+
# Trap safety: never raise
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def invoke_previous_handler(previous, sig, current_handler = nil)
|
|
124
|
+
return if previous.nil? || previous == 'DEFAULT' || previous == 'SYSTEM_DEFAULT'
|
|
125
|
+
return if previous == 'IGNORE'
|
|
126
|
+
return if current_handler && previous.equal?(current_handler)
|
|
127
|
+
|
|
128
|
+
previous.call(sig) if previous.respond_to?(:call)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
# Never bubble from trap context
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def previous_signal_handlers
|
|
134
|
+
@previous_signal_handlers ||= {}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
61
138
|
# @return [String] Durable consumer name
|
|
62
139
|
attr_reader :durable
|
|
63
140
|
# @return [Integer] Batch size for message fetching
|
|
@@ -252,6 +329,8 @@ module JetstreamBridge
|
|
|
252
329
|
#
|
|
253
330
|
def stop!
|
|
254
331
|
@lifecycle_state.stop!
|
|
332
|
+
# Allow other consumers to continue receiving signals without stale references
|
|
333
|
+
self.class.unregister_consumer_for_signals(self)
|
|
255
334
|
Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
|
|
256
335
|
end
|
|
257
336
|
|
|
@@ -394,15 +473,8 @@ module JetstreamBridge
|
|
|
394
473
|
end
|
|
395
474
|
|
|
396
475
|
def setup_signal_handlers
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
# CRITICAL: Only set flags in trap context, no I/O or mutex operations
|
|
400
|
-
# Logging and other operations are unsafe from signal handlers
|
|
401
|
-
@lifecycle_state.signal!(sig)
|
|
402
|
-
end
|
|
403
|
-
end
|
|
404
|
-
rescue ArgumentError => e
|
|
405
|
-
# Signal handlers may not be available in all environments (e.g., threads)
|
|
476
|
+
self.class.register_consumer_for_signals(self)
|
|
477
|
+
rescue StandardError => e
|
|
406
478
|
Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
407
479
|
end
|
|
408
480
|
|
|
@@ -41,10 +41,9 @@ module JetstreamBridge
|
|
|
41
41
|
# Bind a subscriber to the existing durable consumer.
|
|
42
42
|
def subscribe!
|
|
43
43
|
if @cfg.push_consumer?
|
|
44
|
-
|
|
44
|
+
subscribe_push_with_fallback
|
|
45
45
|
else
|
|
46
|
-
|
|
47
|
-
subscribe_without_verification!
|
|
46
|
+
subscribe_pull_with_fallback
|
|
48
47
|
end
|
|
49
48
|
end
|
|
50
49
|
|
|
@@ -65,25 +64,47 @@ module JetstreamBridge
|
|
|
65
64
|
# Push consumers deliver messages directly to a subscription subject
|
|
66
65
|
# No JetStream API calls needed - just subscribe to the delivery subject
|
|
67
66
|
delivery_subject = @cfg.push_delivery_subject
|
|
67
|
+
queue_group = @cfg.push_consumer_group_name
|
|
68
68
|
|
|
69
69
|
create_subscription_with_fallback(
|
|
70
|
-
description: "push subscription for consumer #{@durable}
|
|
70
|
+
description: "push subscription for consumer #{@durable} " \
|
|
71
|
+
"(stream=#{stream_name}, delivery=#{delivery_subject}, queue=#{queue_group})",
|
|
71
72
|
primary_check: ->(nc) { nc.respond_to?(:subscribe) },
|
|
72
73
|
primary_action: lambda do |nc|
|
|
73
|
-
sub = nc.subscribe(delivery_subject)
|
|
74
|
+
sub = nc.subscribe(delivery_subject, queue: queue_group)
|
|
74
75
|
Logging.info(
|
|
75
76
|
"Created push subscription for consumer #{@durable} " \
|
|
76
|
-
"(stream=#{stream_name}, delivery=#{delivery_subject})",
|
|
77
|
+
"(stream=#{stream_name}, delivery=#{delivery_subject}, queue=#{queue_group})",
|
|
77
78
|
tag: 'JetstreamBridge::Consumer'
|
|
78
79
|
)
|
|
79
80
|
sub
|
|
80
81
|
end,
|
|
81
82
|
fallback_name: :subscribe,
|
|
82
83
|
fallback_available: -> { @jts.respond_to?(:subscribe) },
|
|
83
|
-
fallback_action: -> { @jts.subscribe(delivery_subject) }
|
|
84
|
+
fallback_action: -> { @jts.subscribe(delivery_subject, queue: queue_group) }
|
|
84
85
|
)
|
|
85
86
|
end
|
|
86
87
|
|
|
88
|
+
def subscribe_push_with_fallback
|
|
89
|
+
subscribe_push!
|
|
90
|
+
rescue JetstreamBridge::ConnectionError, StandardError => e
|
|
91
|
+
Logging.warn(
|
|
92
|
+
"Push subscription failed (#{e.class}: #{e.message}); falling back to pull subscription for #{@durable}",
|
|
93
|
+
tag: 'JetstreamBridge::Consumer'
|
|
94
|
+
)
|
|
95
|
+
subscribe_without_verification!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def subscribe_pull_with_fallback
|
|
99
|
+
subscribe_without_verification!
|
|
100
|
+
rescue JetstreamBridge::ConnectionError, StandardError => e
|
|
101
|
+
Logging.warn(
|
|
102
|
+
"Pull subscription failed (#{e.class}: #{e.message}); falling back to push subscription for #{@durable}",
|
|
103
|
+
tag: 'JetstreamBridge::Consumer'
|
|
104
|
+
)
|
|
105
|
+
subscribe_push!
|
|
106
|
+
end
|
|
107
|
+
|
|
87
108
|
private
|
|
88
109
|
|
|
89
110
|
def build_consumer_config(durable, filter_subject)
|
|
@@ -98,8 +119,11 @@ module JetstreamBridge
|
|
|
98
119
|
backoff: Duration.normalize_list_to_seconds(JetstreamBridge.config.backoff)
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
# Add deliver_subject for push consumers
|
|
102
|
-
|
|
122
|
+
# Add deliver_subject and deliver_group for push consumers
|
|
123
|
+
if @cfg.push_consumer?
|
|
124
|
+
config[:deliver_subject] = @cfg.push_delivery_subject
|
|
125
|
+
config[:deliver_group] = @cfg.push_consumer_group_name
|
|
126
|
+
end
|
|
103
127
|
|
|
104
128
|
config
|
|
105
129
|
end
|
|
@@ -114,6 +114,10 @@ module JetstreamBridge
|
|
|
114
114
|
# Only used when consumer_mode is :push
|
|
115
115
|
# @return [String, nil]
|
|
116
116
|
attr_accessor :delivery_subject
|
|
117
|
+
# Queue group / deliver_group for push consumers (optional, defaults to durable_name or app_name)
|
|
118
|
+
# Only used when consumer_mode is :push. Determines how push consumers load-balance.
|
|
119
|
+
# @return [String, nil]
|
|
120
|
+
attr_accessor :push_consumer_group
|
|
117
121
|
|
|
118
122
|
def initialize
|
|
119
123
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
@@ -142,6 +146,7 @@ module JetstreamBridge
|
|
|
142
146
|
# Consumer mode
|
|
143
147
|
@consumer_mode = :pull
|
|
144
148
|
@delivery_subject = nil
|
|
149
|
+
@push_consumer_group = nil
|
|
145
150
|
end
|
|
146
151
|
|
|
147
152
|
# Apply a configuration preset
|
|
@@ -223,11 +228,26 @@ module JetstreamBridge
|
|
|
223
228
|
"#{destination_subject}.worker"
|
|
224
229
|
end
|
|
225
230
|
|
|
231
|
+
# Queue group for push consumers. Controls deliver_group and queue subscription.
|
|
232
|
+
#
|
|
233
|
+
# @return [String] Queue group name
|
|
234
|
+
# @raise [InvalidSubjectError, MissingConfigurationError] If derived components invalid
|
|
235
|
+
def push_consumer_group_name
|
|
236
|
+
group = push_consumer_group
|
|
237
|
+
group = durable_name if group.to_s.strip.empty?
|
|
238
|
+
group = app_name if group.to_s.strip.empty?
|
|
239
|
+
|
|
240
|
+
validate_subject_component!(group, 'push_consumer_group')
|
|
241
|
+
group
|
|
242
|
+
end
|
|
243
|
+
|
|
226
244
|
# Check if using pull consumer mode.
|
|
227
245
|
#
|
|
228
246
|
# @return [Boolean]
|
|
229
247
|
def pull_consumer?
|
|
230
248
|
consumer_mode.to_sym == :pull
|
|
249
|
+
rescue NoMethodError
|
|
250
|
+
false
|
|
231
251
|
end
|
|
232
252
|
|
|
233
253
|
# Check if using push consumer mode.
|
|
@@ -235,6 +255,8 @@ module JetstreamBridge
|
|
|
235
255
|
# @return [Boolean]
|
|
236
256
|
def push_consumer?
|
|
237
257
|
consumer_mode.to_sym == :push
|
|
258
|
+
rescue NoMethodError
|
|
259
|
+
false
|
|
238
260
|
end
|
|
239
261
|
|
|
240
262
|
# Validate all configuration settings.
|
|
@@ -252,6 +274,7 @@ module JetstreamBridge
|
|
|
252
274
|
validate_numeric_constraints!(errors)
|
|
253
275
|
validate_backoff!(errors)
|
|
254
276
|
validate_consumer_mode!(errors)
|
|
277
|
+
validate_push_consumer!(errors)
|
|
255
278
|
|
|
256
279
|
raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
|
|
257
280
|
|
|
@@ -298,5 +321,13 @@ module JetstreamBridge
|
|
|
298
321
|
|
|
299
322
|
errors << 'consumer_mode must be :pull or :push' unless [:pull, :push].include?(consumer_mode.to_sym)
|
|
300
323
|
end
|
|
324
|
+
|
|
325
|
+
def validate_push_consumer!(errors)
|
|
326
|
+
return unless push_consumer?
|
|
327
|
+
|
|
328
|
+
push_consumer_group_name
|
|
329
|
+
rescue ConfigurationError => e
|
|
330
|
+
errors << e.message
|
|
331
|
+
end
|
|
301
332
|
end
|
|
302
333
|
end
|
|
@@ -44,6 +44,9 @@ module JetstreamBridge
|
|
|
44
44
|
}.freeze
|
|
45
45
|
|
|
46
46
|
VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
|
|
47
|
+
REFRESH_RETRY_BASE_DELAY = 0.01
|
|
48
|
+
REFRESH_RETRY_MAX_DELAY = 30.0
|
|
49
|
+
REFRESH_RETRY_MAX_ATTEMPTS = 30
|
|
47
50
|
|
|
48
51
|
# Class-level mutex for thread-safe connection initialization
|
|
49
52
|
# Using class variable to avoid race condition in mutex creation
|
|
@@ -238,36 +241,10 @@ module JetstreamBridge
|
|
|
238
241
|
tag: 'JetstreamBridge::Connection'
|
|
239
242
|
)
|
|
240
243
|
|
|
241
|
-
|
|
242
|
-
max_attempts = 3
|
|
243
|
-
attempts = 0
|
|
244
|
-
success = false
|
|
245
|
-
|
|
246
|
-
while attempts < max_attempts && !success
|
|
247
|
-
attempts += 1
|
|
248
|
-
begin
|
|
249
|
-
refresh_jetstream_context
|
|
250
|
-
success = true
|
|
251
|
-
rescue StandardError => e
|
|
252
|
-
if attempts < max_attempts
|
|
253
|
-
delay = 0.5 * (2**(attempts - 1)) # 0.5s, 1s, 2s
|
|
254
|
-
Logging.warn(
|
|
255
|
-
"JetStream context refresh attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
256
|
-
"Retrying in #{delay}s...",
|
|
257
|
-
tag: 'JetstreamBridge::Connection'
|
|
258
|
-
)
|
|
259
|
-
sleep(delay)
|
|
260
|
-
else
|
|
261
|
-
Logging.error(
|
|
262
|
-
"Failed to refresh JetStream context after #{attempts} attempts. " \
|
|
263
|
-
'Will retry on next reconnect.',
|
|
264
|
-
tag: 'JetstreamBridge::Connection'
|
|
265
|
-
)
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
end
|
|
244
|
+
success = refresh_jetstream_with_retry?
|
|
269
245
|
|
|
270
246
|
@reconnecting = false
|
|
247
|
+
start_refresh_retry_loop unless success
|
|
271
248
|
end
|
|
272
249
|
|
|
273
250
|
@nc.on_disconnect do |reason|
|
|
@@ -504,11 +481,95 @@ module JetstreamBridge
|
|
|
504
481
|
raise
|
|
505
482
|
end
|
|
506
483
|
|
|
484
|
+
def refresh_jetstream_with_retry?(max_attempts: 3, base_delay: 0.5)
|
|
485
|
+
attempts = 0
|
|
486
|
+
while attempts < max_attempts
|
|
487
|
+
attempts += 1
|
|
488
|
+
begin
|
|
489
|
+
refresh_jetstream_context
|
|
490
|
+
return true
|
|
491
|
+
rescue StandardError => e
|
|
492
|
+
if attempts < max_attempts
|
|
493
|
+
delay = refresh_retry_sleep_duration(attempts, base: base_delay)
|
|
494
|
+
Logging.warn(
|
|
495
|
+
"JetStream context refresh attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
496
|
+
"Retrying in #{delay}s...",
|
|
497
|
+
tag: 'JetstreamBridge::Connection'
|
|
498
|
+
)
|
|
499
|
+
sleep(delay)
|
|
500
|
+
else
|
|
501
|
+
Logging.error(
|
|
502
|
+
"Failed to refresh JetStream context after #{attempts} attempts. " \
|
|
503
|
+
'Starting background retry loop.',
|
|
504
|
+
tag: 'JetstreamBridge::Connection'
|
|
505
|
+
)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
false
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def start_refresh_retry_loop(initial_delay: REFRESH_RETRY_BASE_DELAY, max_attempts: REFRESH_RETRY_MAX_ATTEMPTS)
|
|
514
|
+
return if @refresh_retry_thread&.alive?
|
|
515
|
+
return unless @nc
|
|
516
|
+
|
|
517
|
+
@refresh_retry_thread = Thread.new do
|
|
518
|
+
Thread.current.report_on_exception = false
|
|
519
|
+
attempts = 0
|
|
520
|
+
delay = initial_delay
|
|
521
|
+
|
|
522
|
+
while @nc&.connected?
|
|
523
|
+
sleep(delay)
|
|
524
|
+
attempts += 1
|
|
525
|
+
break if max_attempts && attempts > max_attempts
|
|
526
|
+
|
|
527
|
+
begin
|
|
528
|
+
refresh_jetstream_context
|
|
529
|
+
Logging.info(
|
|
530
|
+
"JetStream context refreshed via background retry after #{attempts} attempt#{'s' if attempts != 1}",
|
|
531
|
+
tag: 'JetstreamBridge::Connection'
|
|
532
|
+
)
|
|
533
|
+
break
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
if defined?(RSpec::Mocks::ExpiredTestDoubleError) &&
|
|
536
|
+
e.is_a?(RSpec::Mocks::ExpiredTestDoubleError)
|
|
537
|
+
Logging.debug(
|
|
538
|
+
'Stopping background JetStream refresh due to expired test double',
|
|
539
|
+
tag: 'JetstreamBridge::Connection'
|
|
540
|
+
)
|
|
541
|
+
break
|
|
542
|
+
end
|
|
543
|
+
delay = refresh_retry_sleep_duration(attempts + 1)
|
|
544
|
+
Logging.warn(
|
|
545
|
+
"Background JetStream refresh attempt #{attempts} failed: #{e.message}. " \
|
|
546
|
+
"Retrying in #{delay}s...",
|
|
547
|
+
tag: 'JetstreamBridge::Connection'
|
|
548
|
+
)
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
ensure
|
|
552
|
+
@refresh_retry_thread = nil
|
|
553
|
+
end
|
|
554
|
+
rescue StandardError => e
|
|
555
|
+
Logging.debug(
|
|
556
|
+
"Could not start refresh retry loop: #{e.class} #{e.message}",
|
|
557
|
+
tag: 'JetstreamBridge::Connection'
|
|
558
|
+
)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def refresh_retry_sleep_duration(attempt, base: REFRESH_RETRY_BASE_DELAY)
|
|
562
|
+
[base * (2**(attempt - 1)), REFRESH_RETRY_MAX_DELAY].min
|
|
563
|
+
end
|
|
564
|
+
|
|
507
565
|
# Expose for class-level helpers (not part of public API)
|
|
508
566
|
attr_reader :nc
|
|
509
567
|
|
|
510
568
|
def jetstream
|
|
511
|
-
@jts
|
|
569
|
+
return @jts if @jts
|
|
570
|
+
|
|
571
|
+
raise ConnectionNotEstablishedError,
|
|
572
|
+
'JetStream context unavailable (refresh pending or failed)'
|
|
512
573
|
end
|
|
513
574
|
|
|
514
575
|
# Mask credentials in NATS URLs:
|
|
@@ -539,6 +600,11 @@ module JetstreamBridge
|
|
|
539
600
|
@jts = nil
|
|
540
601
|
end
|
|
541
602
|
|
|
603
|
+
if @refresh_retry_thread&.alive?
|
|
604
|
+
@refresh_retry_thread.kill
|
|
605
|
+
@refresh_retry_thread = nil
|
|
606
|
+
end
|
|
607
|
+
|
|
542
608
|
# Always invalidate health check cache
|
|
543
609
|
@cached_health_status = nil
|
|
544
610
|
@last_health_check = nil
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module ConsumerModeResolver
|
|
5
|
+
VALID_MODES = [:pull, :push].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Resolve consumer mode for a given app.
|
|
10
|
+
#
|
|
11
|
+
# Priority:
|
|
12
|
+
# 1) explicit override (symbol/string)
|
|
13
|
+
# 2) env map CONSUMER_MODES="app:mode,..."
|
|
14
|
+
# 3) per-app env CONSUMER_MODE_<APP_NAME>
|
|
15
|
+
# 4) shared env CONSUMER_MODE
|
|
16
|
+
# 5) fallback (default :pull)
|
|
17
|
+
def resolve(app_name:, override: nil, fallback: :pull)
|
|
18
|
+
return normalize(override) if override
|
|
19
|
+
|
|
20
|
+
from_env = env_for(app_name)
|
|
21
|
+
return from_env if from_env
|
|
22
|
+
|
|
23
|
+
normalize(fallback)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def env_for(app_name)
|
|
27
|
+
from_map(app_name) || app_specific(app_name) || shared
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def from_map(app_name)
|
|
31
|
+
env_map = ENV.fetch('CONSUMER_MODES', nil)
|
|
32
|
+
return nil if env_map.to_s.strip.empty?
|
|
33
|
+
|
|
34
|
+
pairs = env_map.split(',').each_with_object({}) do |pair, memo|
|
|
35
|
+
key, mode = pair.split(':', 2).map { |p| p&.strip }
|
|
36
|
+
memo[key] = mode if key && mode
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
normalize(pairs[app_name.to_s]) if pairs.key?(app_name.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def app_specific(app_name)
|
|
43
|
+
key = "CONSUMER_MODE_#{app_name.to_s.upcase}"
|
|
44
|
+
return nil unless ENV.key?(key)
|
|
45
|
+
|
|
46
|
+
normalize(ENV.fetch(key, nil))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def shared
|
|
50
|
+
return nil unless ENV['CONSUMER_MODE']
|
|
51
|
+
|
|
52
|
+
normalize(ENV.fetch('CONSUMER_MODE', nil))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize(mode)
|
|
56
|
+
return nil if mode.nil? || mode.to_s.strip.empty?
|
|
57
|
+
|
|
58
|
+
m = mode.to_s.downcase.to_sym
|
|
59
|
+
return m if VALID_MODES.include?(m)
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, "Invalid consumer mode #{mode.inspect}. Use :pull or :push."
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative 'consumer/subscription_manager'
|
|
|
6
6
|
require_relative 'core/logging'
|
|
7
7
|
require_relative 'core/config'
|
|
8
8
|
require_relative 'core/connection'
|
|
9
|
+
require_relative 'core/consumer_mode_resolver'
|
|
9
10
|
|
|
10
11
|
module JetstreamBridge
|
|
11
12
|
# Dedicated provisioning orchestrator to keep connection concerns separate.
|
|
@@ -23,6 +24,8 @@ module JetstreamBridge
|
|
|
23
24
|
# @param nats_url [String] NATS connection URL
|
|
24
25
|
# @param logger [Logger] Logger used for progress output
|
|
25
26
|
# @param shared_config [Hash] Additional config applied to both directions
|
|
27
|
+
# @param consumer_modes [Hash,nil] Per-app consumer modes { 'system_a' => :pull, 'system_b' => :push }
|
|
28
|
+
# @param consumer_mode [Symbol] Legacy/shared consumer mode for both directions (overridden by consumer_modes)
|
|
26
29
|
#
|
|
27
30
|
# @return [void]
|
|
28
31
|
def provision_bidirectional!(
|
|
@@ -31,14 +34,26 @@ module JetstreamBridge
|
|
|
31
34
|
stream_name: 'sync-stream',
|
|
32
35
|
nats_url: ENV.fetch('NATS_URL', 'nats://nats:4222'),
|
|
33
36
|
logger: Logger.new($stdout),
|
|
37
|
+
consumer_modes: nil,
|
|
38
|
+
consumer_mode: :pull,
|
|
34
39
|
**shared_config
|
|
35
40
|
)
|
|
41
|
+
modes = build_consumer_mode_map(app_a, app_b, consumer_modes, consumer_mode)
|
|
42
|
+
|
|
36
43
|
[
|
|
37
44
|
{ app_name: app_a, destination_app: app_b },
|
|
38
45
|
{ app_name: app_b, destination_app: app_a }
|
|
39
46
|
].each do |direction|
|
|
47
|
+
direction_mode = modes[direction[:app_name]] || consumer_mode
|
|
40
48
|
logger&.info "Provisioning #{direction[:app_name]} -> #{direction[:destination_app]}"
|
|
41
|
-
configure_direction(
|
|
49
|
+
configure_direction(
|
|
50
|
+
direction,
|
|
51
|
+
stream_name: stream_name,
|
|
52
|
+
nats_url: nats_url,
|
|
53
|
+
logger: logger,
|
|
54
|
+
consumer_mode: direction_mode,
|
|
55
|
+
shared_config: shared_config
|
|
56
|
+
)
|
|
42
57
|
|
|
43
58
|
begin
|
|
44
59
|
JetstreamBridge.startup!
|
|
@@ -49,7 +64,28 @@ module JetstreamBridge
|
|
|
49
64
|
end
|
|
50
65
|
end
|
|
51
66
|
|
|
52
|
-
def
|
|
67
|
+
def build_consumer_mode_map(app_a, app_b, consumer_modes, fallback_mode)
|
|
68
|
+
app_a_key = app_a.to_s
|
|
69
|
+
app_b_key = app_b.to_s
|
|
70
|
+
normalized_fallback = ConsumerModeResolver.normalize(fallback_mode)
|
|
71
|
+
|
|
72
|
+
if consumer_modes
|
|
73
|
+
normalized = consumer_modes.transform_keys(&:to_s).transform_values do |v|
|
|
74
|
+
ConsumerModeResolver.normalize(v)
|
|
75
|
+
end
|
|
76
|
+
normalized[app_a_key] ||= normalized_fallback
|
|
77
|
+
normalized[app_b_key] ||= normalized_fallback
|
|
78
|
+
return normalized
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
app_a_key => ConsumerModeResolver.resolve(app_name: app_a_key, fallback: normalized_fallback),
|
|
83
|
+
app_b_key => ConsumerModeResolver.resolve(app_name: app_b_key, fallback: normalized_fallback)
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
private :build_consumer_mode_map
|
|
87
|
+
|
|
88
|
+
def configure_direction(direction, stream_name:, nats_url:, logger:, consumer_mode:, shared_config:)
|
|
53
89
|
JetstreamBridge.configure do |cfg|
|
|
54
90
|
cfg.nats_urls = nats_url
|
|
55
91
|
cfg.app_name = direction[:app_name]
|
|
@@ -59,8 +95,11 @@ module JetstreamBridge
|
|
|
59
95
|
cfg.use_outbox = false
|
|
60
96
|
cfg.use_inbox = false
|
|
61
97
|
cfg.logger = logger if logger
|
|
98
|
+
cfg.consumer_mode = consumer_mode
|
|
62
99
|
|
|
63
100
|
shared_config.each do |key, value|
|
|
101
|
+
next if key.to_sym == :consumer_mode
|
|
102
|
+
|
|
64
103
|
setter = "#{key}="
|
|
65
104
|
cfg.public_send(setter, value) if cfg.respond_to?(setter)
|
|
66
105
|
end
|
data/lib/jetstream_bridge.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jetstream_bridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 7.0.
|
|
4
|
+
version: 7.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Attara
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-01-
|
|
11
|
+
date: 2026-01-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb
|
|
138
138
|
- lib/jetstream_bridge.rb
|
|
139
139
|
- lib/jetstream_bridge/config_helpers.rb
|
|
140
|
+
- lib/jetstream_bridge/config_helpers/lifecycle.rb
|
|
140
141
|
- lib/jetstream_bridge/consumer/consumer.rb
|
|
141
142
|
- lib/jetstream_bridge/consumer/consumer_state.rb
|
|
142
143
|
- lib/jetstream_bridge/consumer/dlq_publisher.rb
|
|
@@ -153,6 +154,7 @@ files:
|
|
|
153
154
|
- lib/jetstream_bridge/core/config_preset.rb
|
|
154
155
|
- lib/jetstream_bridge/core/connection.rb
|
|
155
156
|
- lib/jetstream_bridge/core/connection_factory.rb
|
|
157
|
+
- lib/jetstream_bridge/core/consumer_mode_resolver.rb
|
|
156
158
|
- lib/jetstream_bridge/core/debug_helper.rb
|
|
157
159
|
- lib/jetstream_bridge/core/duration.rb
|
|
158
160
|
- lib/jetstream_bridge/core/logging.rb
|