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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 471b7dd68739c9de058cbaeedb800dbb5c0e1eadaa2d54602eee34d8469f2668
4
- data.tar.gz: 7d16630ce0e83b6d6fb1ab6f9e6c5151a6aa5df7467810f950fd984e035bb9f0
3
+ metadata.gz: b438f568ca21ece1da4e01f0fcea10ab2a2ec893e56a988a54808108f3e46830
4
+ data.tar.gz: d7733a1316e720b74d7ac3e887e48aeb49923a7aa555612249ee9ca90e0e3c65
5
5
  SHA512:
6
- metadata.gz: d58a47de952678819cd5681b750907ca9d56be3bf9799d5e6f614acd828c9d86da80c7be1006280cbfa42c88ebe8fd6a804da829b5f73672dd59766955df1d16
7
- data.tar.gz: f963140e8a639fc285b633f112df806ff3638e3d83f1b834702d91a47d4a2f28c704071c8ae2f3d763c6d750aa708e12babe33872dbb3778d53058965cd987e0
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
- ```markdown
33
- ┌─────────────────┐ ┌─────────────────┐
34
- Application │ │ Application │
35
- "api" │ │ "worker" │
36
- ├─────────────────┤ ├─────────────────┤
37
- │ │ │ │
38
- │ Publisher │ │ Publisher │
39
- (Optional │ │ (Optional │
40
- │ Outbox) │ │ Outbox) │
41
- │ │ │ │
42
- └────────┬────────┘ └────────┬────────┘
43
- │ │
44
- Publish to: │ Publish to:
45
- api.sync.worker │ worker.sync.api
46
- │ │
47
- └──────────────┐ ┌────────────┘
48
- │ │
49
- ▼ ▼
50
- ┌──────────────────────┐
51
- │ NATS JetStream │
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
- ```markdown
220
- Stream: "jetstream-bridge-stream"
221
- ├── Subjects:
222
- │ ├── api.sync.worker (api publishes, worker consumes)
223
- │ ├── worker.sync.api (worker publishes, api consumes)
224
- │ ├── api.sync.dlq (api's dead letter queue)
225
- │ └── worker.sync.dlq (worker's dead letter queue)
226
-
227
- ├── Retention: workqueue (messages deleted after ack)
228
- ├── Storage: file (persistent on disk)
229
- └── Consumers:
230
- ├── api-workers (filters: worker.sync.api)
231
- └── worker-workers (filters: api.sync.worker)
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
- ```markdown
319
- ┌──────────────────────────────────────────────────────────────┐
320
- 1. Application calls JetstreamBridge.publish(...)
321
- └──────────────────────┬───────────────────────────────────────┘
322
-
323
-
324
- ┌──────────────────────────────────────────────────────────────┐
325
- │ 2. Publisher builds envelope │
326
- │ - Generate event_id (UUID) │
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
- ```markdown
366
- ┌──────────────────────────────────────────────────────────────┐
367
- 1. Application creates Consumer.new { |event| ... }
368
- └──────────────────────┬───────────────────────────────────────┘
369
-
370
-
371
- ┌──────────────────────────────────────────────────────────────┐
372
- 2. SubscriptionManager ensures durable consumer │
373
- - Consumer: {app_name}-workers │
374
- - Filter: {destination_app}.sync.{app_name} │
375
- - Create if not exists (idempotent)
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
- ```markdown
1071
- 0.05s → 0.1s → 0.2s → 0.4s → 0.8s → 1.0s (max)
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
- ```markdown
48
- Total Connections = (Web Workers × Threads) + (Consumers × 3) + 10 buffer
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
- If you're working on the jetstream_bridge gem itself:
413
+ Use the published gem in your application (no local path):
414
414
 
415
- ```bash
416
- # Build the gem
417
- gem build jetstream_bridge.gemspec
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
- If this is a local modification, you can point your Gemfile to the local path:
420
+ Then update:
427
421
 
428
- ```ruby
429
- # Gemfile (temporary for testing)
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 ActiveRecord::Base.timestamped_migrations
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
- app = rails_app
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 = overrides.fetch(:consumer_mode, config.consumer_mode || :pull)
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
- %w[INT TERM].each do |sig|
398
- Signal.trap(sig) do
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
- subscribe_push!
44
+ subscribe_push_with_fallback
45
45
  else
46
- # Always bypass consumer_info to avoid requiring JetStream API permissions at runtime.
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} (stream=#{stream_name}, delivery=#{delivery_subject})",
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
- config[:deliver_subject] = @cfg.push_delivery_subject if @cfg.push_consumer?
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
- # Retry JetStream context refresh with exponential backoff
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(direction, stream_name, nats_url, logger, shared_config)
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 configure_direction(direction, stream_name, nats_url, logger, shared_config)
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
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '7.0.0'
7
+ VERSION = '7.0.2'
8
8
  end
@@ -129,6 +129,7 @@ module JetstreamBridge
129
129
  def reset!
130
130
  @config = nil
131
131
  @connection_initialized = false
132
+ Consumer.reset_signal_handlers! if defined?(Consumer)
132
133
  end
133
134
 
134
135
  # Initialize the JetStream Bridge connection and topology
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.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-30 00:00:00.000000000 Z
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