jetstream_bridge 4.0.4 → 4.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/README.md +22 -1402
  4. data/docs/GETTING_STARTED.md +92 -0
  5. data/docs/PRODUCTION.md +503 -0
  6. data/docs/TESTING.md +414 -0
  7. data/lib/jetstream_bridge/consumer/consumer.rb +101 -5
  8. data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +17 -3
  9. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +19 -7
  10. data/lib/jetstream_bridge/consumer/message_processor.rb +88 -52
  11. data/lib/jetstream_bridge/consumer/subscription_manager.rb +24 -15
  12. data/lib/jetstream_bridge/core/bridge_helpers.rb +85 -0
  13. data/lib/jetstream_bridge/core/config.rb +27 -4
  14. data/lib/jetstream_bridge/core/connection.rb +162 -13
  15. data/lib/jetstream_bridge/core.rb +8 -0
  16. data/lib/jetstream_bridge/models/inbox_event.rb +13 -7
  17. data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
  18. data/lib/jetstream_bridge/publisher/publisher.rb +10 -5
  19. data/lib/jetstream_bridge/rails/integration.rb +153 -0
  20. data/lib/jetstream_bridge/rails/railtie.rb +53 -0
  21. data/lib/jetstream_bridge/rails.rb +5 -0
  22. data/lib/jetstream_bridge/tasks/install.rake +1 -1
  23. data/lib/jetstream_bridge/test_helpers/fixtures.rb +41 -0
  24. data/lib/jetstream_bridge/test_helpers/integration_helpers.rb +77 -0
  25. data/lib/jetstream_bridge/test_helpers/matchers.rb +98 -0
  26. data/lib/jetstream_bridge/test_helpers/mock_nats.rb +524 -0
  27. data/lib/jetstream_bridge/test_helpers.rb +85 -121
  28. data/lib/jetstream_bridge/topology/overlap_guard.rb +46 -0
  29. data/lib/jetstream_bridge/topology/stream.rb +7 -4
  30. data/lib/jetstream_bridge/version.rb +1 -1
  31. data/lib/jetstream_bridge.rb +138 -63
  32. metadata +32 -12
  33. data/lib/jetstream_bridge/railtie.rb +0 -49
@@ -50,6 +50,7 @@ module JetstreamBridge
50
50
  # Orchestrates parse → handler → ack/nak → DLQ
51
51
  class MessageProcessor
52
52
  UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
53
+ ActionResult = Struct.new(:action, :ctx, :error, :delay, keyword_init: true)
53
54
 
54
55
  attr_reader :middleware_chain
55
56
 
@@ -61,12 +62,15 @@ module JetstreamBridge
61
62
  @middleware_chain = middleware_chain || ConsumerMiddleware::MiddlewareChain.new
62
63
  end
63
64
 
64
- def handle_message(msg)
65
- ctx = MessageContext.build(msg)
66
- event = parse_message(msg, ctx)
67
- return unless event
65
+ def handle_message(msg, auto_ack: true)
66
+ ctx = MessageContext.build(msg)
67
+ event, early_action = parse_message(msg, ctx)
68
+ return apply_action(msg, early_action) if early_action && auto_ack
69
+ return early_action if early_action
68
70
 
69
- process_event(msg, event, ctx)
71
+ result = process_event(msg, event, ctx)
72
+ apply_action(msg, result) if auto_ack
73
+ result
70
74
  rescue StandardError => e
71
75
  backtrace = e.backtrace&.first(5)&.join("\n ")
72
76
  Logging.error(
@@ -74,32 +78,35 @@ module JetstreamBridge
74
78
  "deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}\n #{backtrace}",
75
79
  tag: 'JetstreamBridge::Consumer'
76
80
  )
77
- safe_nak(msg, ctx, e)
81
+ action = ActionResult.new(action: :nak, ctx: ctx, error: e)
82
+ apply_action(msg, action) if auto_ack
83
+ action
78
84
  end
79
85
 
80
86
  private
81
87
 
82
88
  def parse_message(msg, ctx)
83
89
  data = msg.data
84
- Oj.load(data, mode: :strict)
90
+ [Oj.load(data, mode: :strict), nil]
85
91
  rescue Oj::ParseError => e
86
92
  dlq_success = @dlq.publish(msg, ctx,
87
93
  reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
88
- if dlq_success
89
- msg.ack
90
- Logging.warn(
91
- "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
92
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
93
- tag: 'JetstreamBridge::Consumer'
94
- )
95
- else
96
- safe_nak(msg, ctx, e)
97
- Logging.error(
98
- "Malformed JSON, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
99
- tag: 'JetstreamBridge::Consumer'
100
- )
101
- end
102
- nil
94
+ action = if dlq_success
95
+ Logging.warn(
96
+ "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
97
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
98
+ tag: 'JetstreamBridge::Consumer'
99
+ )
100
+ ActionResult.new(action: :ack, ctx: ctx)
101
+ else
102
+ Logging.error(
103
+ "Malformed JSON, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
104
+ tag: 'JetstreamBridge::Consumer'
105
+ )
106
+ ActionResult.new(action: :nak, ctx: ctx, error: e,
107
+ delay: backoff_delay(ctx, e))
108
+ end
109
+ [nil, action]
103
110
  end
104
111
 
105
112
  def process_event(msg, event_hash, ctx)
@@ -113,33 +120,14 @@ module JetstreamBridge
113
120
  call_handler(event, event_hash, ctx)
114
121
  end
115
122
 
116
- msg.ack
117
- Logging.info(
118
- "ACK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
119
- tag: 'JetstreamBridge::Consumer'
120
- )
123
+ ActionResult.new(action: :ack, ctx: ctx)
121
124
  rescue *UNRECOVERABLE_ERRORS => e
122
- dlq_success = @dlq.publish(msg, ctx,
123
- reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
124
- if dlq_success
125
- msg.ack
126
- Logging.warn(
127
- "DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
128
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
129
- tag: 'JetstreamBridge::Consumer'
130
- )
131
- else
132
- safe_nak(msg, ctx, e)
133
- Logging.error(
134
- "Unrecoverable error, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
135
- tag: 'JetstreamBridge::Consumer'
136
- )
137
- end
125
+ dlq_action(msg, ctx, e, reason: 'unrecoverable')
138
126
  rescue StandardError => e
139
- ack_or_nak(msg, ctx, e)
127
+ ack_or_nak_action(msg, ctx, e)
140
128
  end
141
129
 
142
- def ack_or_nak(msg, ctx, error)
130
+ def ack_or_nak_action(msg, ctx, error)
143
131
  max_deliver = JetstreamBridge.config.max_deliver.to_i
144
132
  if ctx.deliveries >= max_deliver
145
133
  # Only ACK if DLQ publish succeeds
@@ -149,36 +137,78 @@ module JetstreamBridge
149
137
  error_message: error.message)
150
138
 
151
139
  if dlq_success
152
- msg.ack
153
140
  Logging.warn(
154
141
  "DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
155
142
  "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
156
143
  tag: 'JetstreamBridge::Consumer'
157
144
  )
145
+ ActionResult.new(action: :ack, ctx: ctx)
158
146
  else
159
- # NAK to retry DLQ publish
160
- safe_nak(msg, ctx, error)
161
147
  Logging.error(
162
148
  "DLQ publish failed at max_deliver, NAKing event_id=#{ctx.event_id} " \
163
149
  "seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
164
150
  tag: 'JetstreamBridge::Consumer'
165
151
  )
152
+ ActionResult.new(action: :nak, ctx: ctx, error: error,
153
+ delay: backoff_delay(ctx, error))
166
154
  end
167
155
  else
168
- safe_nak(msg, ctx, error)
169
156
  Logging.warn(
170
157
  "NAK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
171
158
  "deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
172
159
  tag: 'JetstreamBridge::Consumer'
173
160
  )
161
+ ActionResult.new(action: :nak, ctx: ctx, error: error,
162
+ delay: backoff_delay(ctx, error))
174
163
  end
175
164
  end
176
165
 
177
- def safe_nak(msg, ctx = nil, error = nil)
166
+ def dlq_action(msg, ctx, error, reason:)
167
+ dlq_success = @dlq.publish(msg, ctx,
168
+ reason: reason, error_class: error.class.name, error_message: error.message)
169
+ if dlq_success
170
+ Logging.warn(
171
+ "DLQ (#{reason}) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
172
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
173
+ tag: 'JetstreamBridge::Consumer'
174
+ )
175
+ ActionResult.new(action: :ack, ctx: ctx)
176
+ else
177
+ Logging.error(
178
+ "DLQ publish failed (#{reason}), NAKing event_id=#{ctx.event_id}",
179
+ tag: 'JetstreamBridge::Consumer'
180
+ )
181
+ ActionResult.new(action: :nak, ctx: ctx, error: error,
182
+ delay: backoff_delay(ctx, error))
183
+ end
184
+ end
185
+
186
+ def apply_action(msg, action_result)
187
+ return unless action_result
188
+
189
+ case action_result.action
190
+ when :ack
191
+ msg.ack
192
+ log_ack(action_result)
193
+ when :nak
194
+ safe_nak(msg, action_result.ctx, action_result.error, delay: action_result.delay)
195
+ end
196
+ action_result
197
+ end
198
+
199
+ def log_ack(result)
200
+ ctx = result.ctx
201
+ Logging.info(
202
+ "ACK event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} deliveries=#{ctx&.deliveries}",
203
+ tag: 'JetstreamBridge::Consumer'
204
+ )
205
+ end
206
+
207
+ def safe_nak(msg, ctx = nil, error = nil, delay: nil)
178
208
  # Use backoff strategy with error context if available
179
209
  if ctx && error && msg.respond_to?(:nak_with_delay)
180
- delay = @backoff.delay(ctx.deliveries.to_i, error)
181
- msg.nak_with_delay(delay)
210
+ nak_delay = delay || @backoff.delay(ctx.deliveries.to_i, error)
211
+ msg.nak_with_delay(nak_delay)
182
212
  else
183
213
  msg.nak
184
214
  end
@@ -209,5 +239,11 @@ module JetstreamBridge
209
239
  def call_handler(event, _event_hash, _ctx)
210
240
  @handler.call(event)
211
241
  end
242
+
243
+ def backoff_delay(ctx, error)
244
+ return nil unless ctx && error
245
+
246
+ @backoff.delay(ctx.deliveries.to_i, error)
247
+ end
212
248
  end
213
249
  end
@@ -80,8 +80,9 @@ module JetstreamBridge
80
80
  ack_policy: 'explicit',
81
81
  deliver_policy: 'all',
82
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) }
83
+ # JetStream expects seconds (the client multiplies by nanoseconds).
84
+ ack_wait: duration_to_seconds(JetstreamBridge.config.ack_wait),
85
+ backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_seconds(d) }
85
86
  }
86
87
  end
87
88
 
@@ -93,8 +94,8 @@ module JetstreamBridge
93
94
  ack_policy: sval(cfg, :ack_policy), # string
94
95
  deliver_policy: sval(cfg, :deliver_policy), # string
95
96
  max_deliver: ival(cfg, :max_deliver), # integer
96
- ack_wait: d_ms(cfg, :ack_wait), # integer ms
97
- backoff_ms: darr_ms(cfg, :backoff) # array of integer ms
97
+ ack_wait_secs: d_secs(cfg, :ack_wait), # integer seconds
98
+ backoff_secs: darr_secs(cfg, :backoff) # array of integer seconds
98
99
  }
99
100
  end
100
101
 
@@ -156,40 +157,48 @@ module JetstreamBridge
156
157
  # - Integers/Floats:
157
158
  # * Server may return large integers in **nanoseconds** → detect and convert.
158
159
  # * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
159
- def d_ms(cfg, key)
160
+ def d_secs(cfg, key)
160
161
  raw = get(cfg, key)
161
- duration_to_ms(raw)
162
+ duration_to_seconds(raw)
162
163
  end
163
164
 
164
165
  # Normalize array of durations to integer milliseconds.
165
- def darr_ms(cfg, key)
166
+ def darr_secs(cfg, key)
166
167
  raw = get(cfg, key)
167
- Array(raw).map { |d| duration_to_ms(d) }
168
+ Array(raw).map { |d| duration_to_seconds(d) }
168
169
  end
169
170
 
170
171
  # ---- duration coercion ----
171
172
 
172
- def duration_to_ms(val)
173
+ def duration_to_seconds(val)
173
174
  return nil if val.nil?
174
175
 
175
176
  case val
176
177
  when Integer
177
178
  # Heuristic: extremely large integers are likely **nanoseconds** from server
178
- # (e.g., 30s => 30_000_000_000 ns). Convert ns → ms.
179
- return (val / 1_000_000.0).round if val >= 1_000_000_000
179
+ # (e.g., 30s => 30_000_000_000 ns). Convert ns → seconds.
180
+ return (val / 1_000_000_000.0).round if val >= 1_000_000_000
180
181
 
181
182
  # otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
182
- Duration.to_millis(val, default_unit: :auto)
183
+ millis = Duration.to_millis(val, default_unit: :auto)
184
+ seconds_from_millis(millis)
183
185
  when Float
184
- Duration.to_millis(val, default_unit: :auto) # treated as seconds
186
+ millis = Duration.to_millis(val, default_unit: :auto)
187
+ seconds_from_millis(millis)
185
188
  when String
186
189
  # Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
187
- Duration.to_millis(val) # default_unit ignored when unit given
190
+ millis = Duration.to_millis(val) # default_unit ignored when unit given
191
+ seconds_from_millis(millis)
188
192
  else
189
- return Duration.to_millis(val.to_f, default_unit: :auto) if val.respond_to?(:to_f)
193
+ return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
190
194
 
191
195
  raise ArgumentError, "invalid duration: #{val.inspect}"
192
196
  end
193
197
  end
198
+
199
+ def seconds_from_millis(millis)
200
+ # Always round up to avoid zero-second waits when sub-second durations are provided.
201
+ [(millis / 1000.0).ceil, 1].max
202
+ end
194
203
  end
195
204
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+ require_relative 'connection'
5
+
6
+ module JetstreamBridge
7
+ module Core
8
+ # Internal helper methods extracted from the main JetstreamBridge module
9
+ # to keep the public API surface focused.
10
+ module BridgeHelpers
11
+ private
12
+
13
+ # Ensure connection is established before use
14
+ # Automatically connects on first use if not already connected
15
+ # Thread-safe and idempotent
16
+ def connect_if_needed!
17
+ return if @connection_initialized
18
+
19
+ startup!
20
+ end
21
+
22
+ # Enforce rate limit on uncached health checks to prevent abuse
23
+ # Max 1 uncached request per 5 seconds per process
24
+ def enforce_health_check_rate_limit!
25
+ @health_check_mutex ||= Mutex.new
26
+ @health_check_mutex.synchronize do
27
+ now = Time.now
28
+ if @last_uncached_health_check
29
+ time_since = now - @last_uncached_health_check
30
+ if time_since < 5
31
+ raise HealthCheckFailedError,
32
+ "Health check rate limit exceeded. Please wait #{(5 - time_since).ceil} second(s)"
33
+ end
34
+ end
35
+ @last_uncached_health_check = now
36
+ end
37
+ end
38
+
39
+ def fetch_stream_info
40
+ jts = Connection.jetstream
41
+ info = jts.stream_info(config.stream_name)
42
+
43
+ # Handle both object-style and hash-style access for compatibility
44
+ config_data = info.config
45
+ state_data = info.state
46
+ subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
47
+ messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
48
+
49
+ {
50
+ exists: true,
51
+ name: config.stream_name,
52
+ subjects: subjects,
53
+ messages: messages
54
+ }
55
+ rescue StandardError => e
56
+ {
57
+ exists: false,
58
+ name: config.stream_name,
59
+ error: "#{e.class}: #{e.message}"
60
+ }
61
+ end
62
+
63
+ def measure_nats_rtt
64
+ # Measure round-trip time using NATS RTT method
65
+ nc = Connection.nc
66
+ start = Time.now
67
+ nc.rtt
68
+ ((Time.now - start) * 1000).round(2)
69
+ rescue StandardError => e
70
+ Logging.warn(
71
+ "Failed to measure NATS RTT: #{e.class} #{e.message}",
72
+ tag: 'JetstreamBridge'
73
+ )
74
+ nil
75
+ end
76
+
77
+ def assign_config_option!(cfg, key, val)
78
+ setter = :"#{key}="
79
+ raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
80
+
81
+ cfg.public_send(setter, val)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -86,6 +86,15 @@ module JetstreamBridge
86
86
  # Applied preset name
87
87
  # @return [Symbol, nil]
88
88
  attr_reader :preset_applied
89
+ # Number of retry attempts for initial connection
90
+ # @return [Integer]
91
+ attr_accessor :connect_retry_attempts
92
+ # Delay between connection retry attempts (in seconds)
93
+ # @return [Integer]
94
+ attr_accessor :connect_retry_delay
95
+ # Enable lazy connection (connect on first use instead of during configure)
96
+ # @return [Boolean]
97
+ attr_accessor :lazy_connect
89
98
 
90
99
  def initialize
91
100
  @nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
@@ -104,6 +113,11 @@ module JetstreamBridge
104
113
  @inbox_model = 'JetstreamBridge::InboxEvent'
105
114
  @logger = nil
106
115
  @preset_applied = nil
116
+
117
+ # Connection management
118
+ @connect_retry_attempts = 3
119
+ @connect_retry_delay = 2
120
+ @lazy_connect = false
107
121
  end
108
122
 
109
123
  # Apply a configuration preset
@@ -219,11 +233,20 @@ module JetstreamBridge
219
233
  private
220
234
 
221
235
  def validate_subject_component!(value, name)
222
- str = value.to_s
223
- if str.match?(/[.*>]/)
224
- raise InvalidSubjectError, "#{name} cannot contain NATS wildcards (., *, >): #{value.inspect}"
236
+ str = value.to_s.strip
237
+ raise MissingConfigurationError, "#{name} cannot be empty" if str.empty?
238
+
239
+ # NATS subject tokens must not contain wildcards, spaces, or control characters
240
+ # Valid characters: alphanumeric, hyphen, underscore
241
+ if str.match?(/[.*>\s\x00-\x1F\x7F]/)
242
+ raise InvalidSubjectError,
243
+ "#{name} contains invalid NATS subject characters (wildcards, spaces, or control chars): #{value.inspect}"
225
244
  end
226
- raise MissingConfigurationError, "#{name} cannot be empty" if str.strip.empty?
245
+
246
+ # NATS has a practical subject length limit
247
+ return unless str.length > 255
248
+
249
+ raise InvalidSubjectError, "#{name} exceeds maximum length (255 characters): #{str.length}"
227
250
  end
228
251
  end
229
252
  end