jetstream_bridge 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +164 -0
  3. data/LICENSE +21 -0
  4. data/README.md +379 -0
  5. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
  6. data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
  7. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +61 -13
  8. data/lib/generators/jetstream_bridge/install/install_generator.rb +4 -2
  9. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +1 -0
  10. data/lib/jetstream_bridge/consumer/consumer.rb +50 -9
  11. data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
  12. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +8 -2
  13. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -61
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +105 -33
  15. data/lib/jetstream_bridge/consumer/subscription_manager.rb +13 -2
  16. data/lib/jetstream_bridge/core/config.rb +37 -1
  17. data/lib/jetstream_bridge/core/connection.rb +80 -3
  18. data/lib/jetstream_bridge/core/connection_factory.rb +102 -0
  19. data/lib/jetstream_bridge/core/debug_helper.rb +107 -0
  20. data/lib/jetstream_bridge/core/duration.rb +8 -1
  21. data/lib/jetstream_bridge/core/logging.rb +20 -7
  22. data/lib/jetstream_bridge/core/model_utils.rb +4 -3
  23. data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
  24. data/lib/jetstream_bridge/errors.rb +39 -0
  25. data/lib/jetstream_bridge/inbox_event.rb +4 -4
  26. data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
  27. data/lib/jetstream_bridge/models/subject.rb +94 -0
  28. data/lib/jetstream_bridge/outbox_event.rb +3 -1
  29. data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
  30. data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
  31. data/lib/jetstream_bridge/railtie.rb +35 -1
  32. data/lib/jetstream_bridge/tasks/install.rake +99 -0
  33. data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
  34. data/lib/jetstream_bridge/topology/stream.rb +16 -8
  35. data/lib/jetstream_bridge/topology/subject_matcher.rb +17 -7
  36. data/lib/jetstream_bridge/topology/topology.rb +1 -1
  37. data/lib/jetstream_bridge/version.rb +1 -1
  38. data/lib/jetstream_bridge.rb +63 -6
  39. metadata +51 -10
  40. data/lib/jetstream_bridge/consumer/backoff_strategy.rb +0 -24
  41. data/lib/jetstream_bridge/consumer/consumer_config.rb +0 -26
  42. data/lib/jetstream_bridge/consumer/message_context.rb +0 -22
@@ -1,24 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Jetstream Bridge configuration
3
+ # JetstreamBridge configuration
4
+ # See https://github.com/your-org/jetstream_bridge for full documentation
5
+ #
6
+ # This initializer configures the JetStream Bridge gem for Rails applications.
7
+ # The gem provides reliable, production-ready message passing between services
8
+ # using NATS JetStream with support for outbox/inbox patterns, DLQ, and more.
4
9
  JetstreamBridge.configure do |config|
5
- # NATS Connection
6
- config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
7
- config.env = ENV.fetch('NATS_ENV', Rails.env)
8
- config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
9
- config.destination_app = ENV['DESTINATION_APP'] # required for cross-app data sync
10
+ # ============================================================================
11
+ # NATS Connection Settings
12
+ # ============================================================================
13
+ # NATS server URLs (comma-separated for cluster)
14
+ config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
10
15
 
11
- # Consumer Tuning
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
+ # Application name (used in subject routing)
21
+ config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
22
+
23
+ # Destination app for cross-app sync (REQUIRED for publishing/consuming)
24
+ config.destination_app = ENV.fetch('DESTINATION_APP', nil)
25
+
26
+ # ============================================================================
27
+ # Consumer Settings
28
+ # ============================================================================
29
+ # Maximum delivery attempts before moving message to DLQ
12
30
  config.max_deliver = 5
13
- config.ack_wait = '30s'
14
- config.backoff = %w[1s 5s 15s 30s 60s]
15
31
 
32
+ # How long to wait for message acknowledgment
33
+ config.ack_wait = '30s'
34
+
35
+ # Exponential backoff delays between retry attempts
36
+ config.backoff = %w[1s 5s 15s 30s 60s]
37
+
38
+ # ============================================================================
16
39
  # Reliability Features
40
+ # ============================================================================
41
+ # Enable transactional outbox pattern for guaranteed message delivery
17
42
  config.use_outbox = false
18
- config.use_inbox = false
19
- config.use_dlq = true
20
43
 
21
- # Models (override if you keep custom AR classes)
44
+ # Enable inbox pattern for exactly-once processing (idempotency)
45
+ config.use_inbox = false
46
+
47
+ # Enable Dead Letter Queue for failed messages
48
+ config.use_dlq = true
49
+
50
+ # ============================================================================
51
+ # Active Record Models
52
+ # ============================================================================
53
+ # Override these if you've created custom model classes
22
54
  config.outbox_model = 'JetstreamBridge::OutboxEvent'
23
- config.inbox_model = 'JetstreamBridge::InboxEvent'
55
+ config.inbox_model = 'JetstreamBridge::InboxEvent'
56
+
57
+ # ============================================================================
58
+ # Logging
59
+ # ============================================================================
60
+ # Custom logger (defaults to Rails.logger if not set)
61
+ # config.logger = Rails.logger
24
62
  end
63
+
64
+ # Validate configuration (optional but recommended for development)
65
+ # Uncomment to enable validation on Rails boot
66
+ # begin
67
+ # JetstreamBridge.config.validate!
68
+ # Rails.logger.info "[JetStream Bridge] Configuration validated successfully"
69
+ # rescue JetstreamBridge::ConfigurationError => e
70
+ # Rails.logger.error "[JetStream Bridge] Configuration error: #{e.message}"
71
+ # raise if Rails.env.production?
72
+ # end
@@ -8,11 +8,13 @@ module JetstreamBridge
8
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, destination_root: destination_root)
11
+ Rails::Generators.invoke('jetstream_bridge:initializer', [], behavior: behavior,
12
+ destination_root: destination_root)
12
13
  end
13
14
 
14
15
  def create_migrations
15
- Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior, destination_root: destination_root)
16
+ Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior,
17
+ destination_root: destination_root)
16
18
  end
17
19
  end
18
20
  end
@@ -8,6 +8,7 @@ module JetstreamBridge
8
8
  # Migrations generator.
9
9
  class MigrationsGenerator < Rails::Generators::Base
10
10
  include Rails::Generators::Migration
11
+
11
12
  source_root File.expand_path('templates', __dir__)
12
13
  desc 'Creates Inbox/Outbox migrations for JetstreamBridge'
13
14
 
@@ -7,7 +7,6 @@ require_relative '../core/duration'
7
7
  require_relative '../core/logging'
8
8
  require_relative '../core/config'
9
9
  require_relative '../core/model_utils'
10
- require_relative 'consumer_config'
11
10
  require_relative 'message_processor'
12
11
  require_relative 'subscription_manager'
13
12
  require_relative 'inbox/inbox_processor'
@@ -20,15 +19,15 @@ module JetstreamBridge
20
19
  IDLE_SLEEP_SECS = 0.05
21
20
  MAX_IDLE_BACKOFF_SECS = 1.0
22
21
 
23
- def initialize(durable_name: JetstreamBridge.config.durable_name,
24
- batch_size: DEFAULT_BATCH_SIZE, &block)
22
+ def initialize(durable_name: nil, batch_size: nil, &block)
25
23
  raise ArgumentError, 'handler block required' unless block_given?
26
24
 
27
25
  @handler = block
28
- @batch_size = Integer(batch_size)
29
- @durable = durable_name
26
+ @batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
27
+ @durable = durable_name || JetstreamBridge.config.durable_name
30
28
  @idle_backoff = IDLE_SLEEP_SECS
31
29
  @running = true
30
+ @shutdown_requested = false
32
31
  @jts = Connection.connect!
33
32
 
34
33
  ensure_destination!
@@ -38,6 +37,7 @@ module JetstreamBridge
38
37
  @inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
39
38
 
40
39
  ensure_subscription!
40
+ setup_signal_handlers
41
41
  end
42
42
 
43
43
  def run!
@@ -49,11 +49,17 @@ module JetstreamBridge
49
49
  processed = process_batch
50
50
  idle_sleep(processed)
51
51
  end
52
+
53
+ # Drain in-flight messages before exiting
54
+ drain_inflight_messages if @shutdown_requested
55
+ Logging.info("Consumer #{@durable} stopped gracefully", tag: 'JetstreamBridge::Consumer')
52
56
  end
53
57
 
54
58
  # Allow external callers to stop a long-running loop gracefully.
55
59
  def stop!
60
+ @shutdown_requested = true
56
61
  @running = false
62
+ Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
57
63
  end
58
64
 
59
65
  private
@@ -103,15 +109,15 @@ module JetstreamBridge
103
109
  0
104
110
  end
105
111
 
106
- def handle_js_error(e)
107
- if recoverable_consumer_error?(e)
112
+ def handle_js_error(error)
113
+ if recoverable_consumer_error?(error)
108
114
  Logging.warn(
109
- "Recovering subscription after error: #{e.class} #{e.message}",
115
+ "Recovering subscription after error: #{error.class} #{error.message}",
110
116
  tag: 'JetstreamBridge::Consumer'
111
117
  )
112
118
  ensure_subscription!
113
119
  else
114
- Logging.error("Fetch failed (non-recoverable): #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
120
+ Logging.error("Fetch failed (non-recoverable): #{error.class} #{error.message}", tag: 'JetstreamBridge::Consumer')
115
121
  end
116
122
  0
117
123
  end
@@ -140,5 +146,40 @@ module JetstreamBridge
140
146
  @idle_backoff = IDLE_SLEEP_SECS
141
147
  end
142
148
  end
149
+
150
+ def setup_signal_handlers
151
+ %w[INT TERM].each do |sig|
152
+ Signal.trap(sig) do
153
+ Logging.info("Received #{sig}, stopping consumer...", tag: 'JetstreamBridge::Consumer')
154
+ stop!
155
+ end
156
+ end
157
+ rescue ArgumentError => e
158
+ # Signal handlers may not be available in all environments (e.g., threads)
159
+ Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
160
+ end
161
+
162
+ def drain_inflight_messages
163
+ return unless @psub
164
+
165
+ Logging.info("Draining in-flight messages...", tag: 'JetstreamBridge::Consumer')
166
+ # Process any pending messages with a short timeout
167
+ 5.times do
168
+ begin
169
+ msgs = @psub.fetch(@batch_size, timeout: 1)
170
+ break if msgs.nil? || msgs.empty?
171
+
172
+ msgs.each { |m| process_one(m) }
173
+ rescue NATS::Timeout, NATS::IO::Timeout
174
+ break
175
+ rescue StandardError => e
176
+ Logging.warn("Error draining messages: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
177
+ break
178
+ end
179
+ end
180
+ Logging.info("Drain complete", tag: 'JetstreamBridge::Consumer')
181
+ rescue StandardError => e
182
+ Logging.error("Drain failed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
183
+ end
143
184
  end
144
185
  end
@@ -11,17 +11,20 @@ module JetstreamBridge
11
11
  end
12
12
 
13
13
  # Sends original payload to DLQ with explanatory headers/context
14
+ # @return [Boolean] true if published successfully, false otherwise
14
15
  def publish(msg, ctx, reason:, error_class:, error_message:)
15
- return unless JetstreamBridge.config.use_dlq
16
+ return true unless JetstreamBridge.config.use_dlq
16
17
 
17
18
  envelope = build_envelope(ctx, reason, error_class, error_message)
18
19
  headers = build_headers(msg.header, reason, ctx.deliveries, envelope)
19
20
  @jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: headers)
21
+ true
20
22
  rescue StandardError => e
21
23
  Logging.error(
22
24
  "DLQ publish failed event_id=#{ctx.event_id}: #{e.class} #{e.message}",
23
25
  tag: 'JetstreamBridge::Consumer'
24
26
  )
27
+ false
25
28
  end
26
29
 
27
30
  private
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'oj'
4
+ require 'securerandom'
4
5
 
5
6
  module JetstreamBridge
6
7
  # Immutable value object for a single NATS message.
7
8
  class InboxMessage
8
9
  attr_reader :msg, :seq, :deliveries, :stream, :subject, :headers, :body, :raw, :event_id, :now
9
10
 
11
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
10
12
  def self.from_nats(msg)
11
13
  meta = (msg.respond_to?(:metadata) && msg.metadata) || nil
12
14
  seq = meta.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
@@ -26,11 +28,14 @@ module JetstreamBridge
26
28
  end
27
29
 
28
30
  id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
29
- id = "seq:#{seq}" if id.empty?
31
+ id = "seq:#{seq}" if id.empty? && seq
32
+ id = SecureRandom.uuid if id.to_s.empty?
30
33
 
31
34
  new(msg, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc, consumer)
32
35
  end
36
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
33
37
 
38
+ # rubocop:disable Metrics/ParameterLists
34
39
  def initialize(msg, seq, deliveries, stream, subject, headers, body, raw, event_id, now, consumer = nil)
35
40
  @msg = msg
36
41
  @seq = seq
@@ -44,6 +49,7 @@ module JetstreamBridge
44
49
  @now = now
45
50
  @consumer = consumer
46
51
  end
52
+ # rubocop:enable Metrics/ParameterLists
47
53
 
48
54
  def body_for_store
49
55
  body.empty? ? raw : body
@@ -59,7 +65,7 @@ module JetstreamBridge
59
65
 
60
66
  def metadata
61
67
  @metadata ||= Struct.new(:num_delivered, :sequence, :consumer, :stream)
62
- .new(deliveries, seq, @consumer, stream)
68
+ .new(deliveries, seq, @consumer, stream)
63
69
  end
64
70
 
65
71
  def ack(*args, **kwargs)
@@ -25,75 +25,51 @@ module JetstreamBridge
25
25
  end
26
26
 
27
27
  def persist_pre(record, msg)
28
- ModelUtils.assign_known_attrs(record, {
29
- event_id: (if ModelUtils.has_columns?(@klass,
30
- :event_id)
31
- msg.event_id
32
- end),
33
- subject: msg.subject,
34
- payload: ModelUtils.json_dump(msg.body_for_store),
35
- headers: ModelUtils.json_dump(msg.headers),
36
- stream: (if ModelUtils.has_columns?(@klass,
37
- :stream)
38
- msg.stream
39
- end),
40
- stream_seq: (if ModelUtils.has_columns?(@klass,
41
- :stream_seq)
42
- msg.seq
43
- end),
44
- deliveries: (if ModelUtils.has_columns?(@klass,
45
- :deliveries)
46
- msg.deliveries
47
- end),
48
- status: 'processing',
49
- last_error: nil,
50
- received_at: (if ModelUtils.has_columns?(@klass,
51
- :received_at)
52
- record.received_at || msg.now
53
- end),
54
- updated_at: (if ModelUtils.has_columns?(@klass,
55
- :updated_at)
56
- msg.now
57
- end)
58
- })
59
- record.save!
28
+ ActiveRecord::Base.transaction do
29
+ attrs = {
30
+ event_id: msg.event_id,
31
+ subject: msg.subject,
32
+ payload: ModelUtils.json_dump(msg.body_for_store),
33
+ headers: ModelUtils.json_dump(msg.headers),
34
+ stream: msg.stream,
35
+ stream_seq: msg.seq,
36
+ deliveries: msg.deliveries,
37
+ status: 'processing',
38
+ last_error: nil,
39
+ received_at: record.respond_to?(:received_at) ? (record.received_at || msg.now) : nil,
40
+ updated_at: record.respond_to?(:updated_at) ? msg.now : nil
41
+ }
42
+ ModelUtils.assign_known_attrs(record, attrs)
43
+ record.save!
44
+ end
60
45
  end
61
46
 
62
47
  def persist_post(record)
63
- now = Time.now.utc
64
- ModelUtils.assign_known_attrs(record, {
65
- status: 'processed',
66
- processed_at: (if ModelUtils.has_columns?(@klass,
67
- :processed_at)
68
- now
69
- end),
70
- updated_at: (if ModelUtils.has_columns?(@klass,
71
- :updated_at)
72
- now
73
- end)
74
- })
75
- record.save!
48
+ ActiveRecord::Base.transaction do
49
+ now = Time.now.utc
50
+ attrs = {
51
+ status: 'processed',
52
+ processed_at: record.respond_to?(:processed_at) ? now : nil,
53
+ updated_at: record.respond_to?(:updated_at) ? now : nil
54
+ }
55
+ ModelUtils.assign_known_attrs(record, attrs)
56
+ record.save!
57
+ end
76
58
  end
77
59
 
78
60
  def persist_failure(record, error)
79
61
  return unless record
80
62
 
81
- now = Time.now.utc
82
- ModelUtils.assign_known_attrs(record, {
83
- status: (if ModelUtils.has_columns?(@klass,
84
- :status)
85
- 'failed'
86
- end),
87
- last_error: (if ModelUtils.has_columns?(@klass,
88
- :last_error)
89
- "#{error.class}: #{error.message}"
90
- end),
91
- updated_at: (if ModelUtils.has_columns?(@klass,
92
- :updated_at)
93
- now
94
- end)
95
- })
96
- record.save!
63
+ ActiveRecord::Base.transaction do
64
+ now = Time.now.utc
65
+ attrs = {
66
+ status: 'failed',
67
+ last_error: "#{error.class}: #{error.message}",
68
+ updated_at: record.respond_to?(:updated_at) ? now : nil
69
+ }
70
+ ModelUtils.assign_known_attrs(record, attrs)
71
+ record.save!
72
+ end
97
73
  rescue StandardError => e
98
74
  Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
99
75
  tag: 'JetstreamBridge::Consumer')
@@ -1,12 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'oj'
4
+ require 'securerandom'
4
5
  require_relative '../core/logging'
5
- require_relative 'message_context'
6
6
  require_relative 'dlq_publisher'
7
- require_relative 'backoff_strategy'
8
7
 
9
8
  module JetstreamBridge
9
+ # Immutable per-message metadata.
10
+ MessageContext = Struct.new(
11
+ :event_id, :deliveries, :subject, :seq, :consumer, :stream,
12
+ keyword_init: true
13
+ ) do
14
+ def self.build(msg)
15
+ new(
16
+ event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
17
+ deliveries: msg.metadata&.num_delivered.to_i,
18
+ subject: msg.subject,
19
+ seq: msg.metadata&.sequence,
20
+ consumer: msg.metadata&.consumer,
21
+ stream: msg.metadata&.stream
22
+ )
23
+ end
24
+ end
25
+
26
+ # Simple exponential backoff strategy for transient failures.
27
+ class BackoffStrategy
28
+ TRANSIENT_ERRORS = [Timeout::Error, IOError].freeze
29
+ MAX_EXPONENT = 6
30
+ MAX_DELAY = 60
31
+ MIN_DELAY = 1
32
+
33
+ # Returns a bounded delay in seconds
34
+ def delay(deliveries, error)
35
+ base = transient?(error) ? 0.5 : 2.0
36
+ power = [deliveries - 1, MAX_EXPONENT].min
37
+ raw = (base * (2**power)).to_i
38
+ raw.clamp(MIN_DELAY, MAX_DELAY)
39
+ end
40
+
41
+ private
42
+
43
+ def transient?(error)
44
+ TRANSIENT_ERRORS.any? { |k| error.is_a?(k) }
45
+ end
46
+ end
47
+
10
48
  # Orchestrates parse → handler → ack/nak → DLQ
11
49
  class MessageProcessor
12
50
  UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
@@ -25,12 +63,13 @@ module JetstreamBridge
25
63
 
26
64
  process_event(msg, event, ctx)
27
65
  rescue StandardError => e
66
+ backtrace = e.backtrace&.first(5)&.join("\n ")
28
67
  Logging.error(
29
68
  "Processor crashed event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} " \
30
- "deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}",
69
+ "deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}\n #{backtrace}",
31
70
  tag: 'JetstreamBridge::Consumer'
32
71
  )
33
- safe_nak(msg)
72
+ safe_nak(msg, ctx, e)
34
73
  end
35
74
 
36
75
  private
@@ -39,14 +78,22 @@ module JetstreamBridge
39
78
  data = msg.data
40
79
  Oj.load(data, mode: :strict)
41
80
  rescue Oj::ParseError => e
42
- @dlq.publish(msg, ctx,
43
- reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
44
- msg.ack
45
- Logging.warn(
46
- "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
47
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
48
- tag: 'JetstreamBridge::Consumer'
49
- )
81
+ dlq_success = @dlq.publish(msg, ctx,
82
+ reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
83
+ if dlq_success
84
+ msg.ack
85
+ Logging.warn(
86
+ "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
87
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
88
+ tag: 'JetstreamBridge::Consumer'
89
+ )
90
+ else
91
+ safe_nak(msg, ctx, e)
92
+ Logging.error(
93
+ "Malformed JSON, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
94
+ tag: 'JetstreamBridge::Consumer'
95
+ )
96
+ end
50
97
  nil
51
98
  end
52
99
 
@@ -58,14 +105,22 @@ module JetstreamBridge
58
105
  tag: 'JetstreamBridge::Consumer'
59
106
  )
60
107
  rescue *UNRECOVERABLE_ERRORS => e
61
- @dlq.publish(msg, ctx,
62
- reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
63
- msg.ack
64
- Logging.warn(
65
- "DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
66
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
67
- tag: 'JetstreamBridge::Consumer'
68
- )
108
+ dlq_success = @dlq.publish(msg, ctx,
109
+ reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
110
+ if dlq_success
111
+ msg.ack
112
+ Logging.warn(
113
+ "DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
114
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
115
+ tag: 'JetstreamBridge::Consumer'
116
+ )
117
+ else
118
+ safe_nak(msg, ctx, e)
119
+ Logging.error(
120
+ "Unrecoverable error, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
121
+ tag: 'JetstreamBridge::Consumer'
122
+ )
123
+ end
69
124
  rescue StandardError => e
70
125
  ack_or_nak(msg, ctx, e)
71
126
  end
@@ -73,14 +128,28 @@ module JetstreamBridge
73
128
  def ack_or_nak(msg, ctx, error)
74
129
  max_deliver = JetstreamBridge.config.max_deliver.to_i
75
130
  if ctx.deliveries >= max_deliver
76
- @dlq.publish(msg, ctx,
77
- reason: 'max_deliver_exceeded', error_class: error.class.name, error_message: error.message)
78
- msg.ack
79
- Logging.warn(
80
- "DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
81
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
82
- tag: 'JetstreamBridge::Consumer'
83
- )
131
+ # Only ACK if DLQ publish succeeds
132
+ dlq_success = @dlq.publish(msg, ctx,
133
+ reason: 'max_deliver_exceeded',
134
+ error_class: error.class.name,
135
+ error_message: error.message)
136
+
137
+ if dlq_success
138
+ msg.ack
139
+ Logging.warn(
140
+ "DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
141
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
142
+ tag: 'JetstreamBridge::Consumer'
143
+ )
144
+ else
145
+ # NAK to retry DLQ publish
146
+ safe_nak(msg, ctx, error)
147
+ Logging.error(
148
+ "DLQ publish failed at max_deliver, NAKing event_id=#{ctx.event_id} " \
149
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
150
+ tag: 'JetstreamBridge::Consumer'
151
+ )
152
+ end
84
153
  else
85
154
  safe_nak(msg, ctx, error)
86
155
  Logging.warn(
@@ -91,11 +160,14 @@ module JetstreamBridge
91
160
  end
92
161
  end
93
162
 
94
- def safe_nak(msg, ctx = nil, _error = nil)
95
- # If your NATS client supports delayed NAKs, uncomment:
96
- # delay = @backoff.delay(ctx&.deliveries.to_i, error) if ctx
97
- # msg.nak(next_delivery_delay: delay)
98
- msg.nak
163
+ def safe_nak(msg, ctx = nil, error = nil)
164
+ # Use backoff strategy with error context if available
165
+ if ctx && error && msg.respond_to?(:nak_with_delay)
166
+ delay = @backoff.delay(ctx.deliveries.to_i, error)
167
+ msg.nak_with_delay(delay)
168
+ else
169
+ msg.nak
170
+ end
99
171
  rescue StandardError => e
100
172
  Logging.error(
101
173
  "Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative '../core/logging'
4
4
  require_relative '../core/duration'
5
- require_relative '../consumer/consumer_config'
6
5
 
7
6
  module JetstreamBridge
8
7
  # Encapsulates durable ensure + subscribe for a pull consumer.
@@ -11,7 +10,7 @@ module JetstreamBridge
11
10
  @jts = jts
12
11
  @durable = durable
13
12
  @cfg = cfg
14
- @desired_cfg = ConsumerConfig.consumer_config(@durable, filter_subject)
13
+ @desired_cfg = build_consumer_config(@durable, filter_subject)
15
14
  @desired_cfg_norm = normalize_consumer_config(@desired_cfg)
16
15
  end
17
16
 
@@ -74,6 +73,18 @@ module JetstreamBridge
74
73
  )
75
74
  end
76
75
 
76
+ def build_consumer_config(durable, filter_subject)
77
+ {
78
+ durable_name: durable,
79
+ filter_subject: filter_subject,
80
+ ack_policy: 'explicit',
81
+ deliver_policy: 'all',
82
+ max_deliver: JetstreamBridge.config.max_deliver,
83
+ ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
84
+ backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
85
+ }
86
+ end
87
+
77
88
  # Normalize both server-returned config objects and our desired hash
78
89
  # into a common hash with consistent units/types for accurate comparison.
79
90
  def normalize_consumer_config(cfg)