jetstream_bridge 4.0.3 → 4.1.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.
@@ -36,11 +36,14 @@ module JetstreamBridge
36
36
  class Publisher
37
37
  # Initialize a new Publisher instance.
38
38
  #
39
+ # Note: The NATS connection should already be established via JetstreamBridge.configure.
40
+ # If not, this will attempt to connect, but it's recommended to call configure first.
41
+ #
39
42
  # @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
40
43
  # Defaults to PublisherRetryStrategy with exponential backoff.
41
44
  # @raise [ConnectionError] If unable to connect to NATS server
42
45
  def initialize(retry_strategy: nil)
43
- @jts = Connection.connect!
46
+ @jts = Connection.jetstream || Connection.connect!
44
47
  @retry_strategy = retry_strategy || PublisherRetryStrategy.new
45
48
  end
46
49
 
@@ -290,7 +293,7 @@ module JetstreamBridge
290
293
  'schema_version' => 1,
291
294
  'event_type' => event_type,
292
295
  'producer' => JetstreamBridge.config.app_name,
293
- 'resource_id' => (payload['id'] || payload[:id]).to_s,
296
+ 'resource_id' => extract_resource_id(payload),
294
297
  'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
295
298
  'trace_id' => options[:trace_id] || SecureRandom.hex(8),
296
299
  'resource_type' => resource_type,
@@ -4,8 +4,16 @@ require_relative 'core/model_codec_setup'
4
4
  require_relative 'core/logging'
5
5
 
6
6
  module JetstreamBridge
7
+ # Rails integration for JetStream Bridge.
8
+ #
9
+ # This Railtie integrates JetStream Bridge with the Rails application lifecycle:
10
+ # - Startup: Connection is established when Rails initializers run (via configure)
11
+ # - Shutdown: Connection is closed when Rails shuts down (at_exit hook)
12
+ # - Restart: Puma/Unicorn workers get fresh connections on fork
13
+ #
7
14
  class Railtie < ::Rails::Railtie
8
15
  # Set up logger to use Rails.logger by default
16
+ # Note: configure() will call startup! which establishes the connection
9
17
  initializer 'jetstream_bridge.logger', before: :initialize_logger do
10
18
  JetstreamBridge.configure do |config|
11
19
  config.logger ||= Rails.logger if defined?(Rails.logger)
@@ -19,14 +27,47 @@ module JetstreamBridge
19
27
  end
20
28
  end
21
29
 
22
- # Validate configuration in development/test
23
- initializer 'jetstream_bridge.validate_config', after: :load_config_initializers do |app|
24
- if Rails.env.development? || Rails.env.test?
25
- app.config.after_initialize do
26
- JetstreamBridge.config.validate! if JetstreamBridge.config.destination_app
27
- rescue JetstreamBridge::ConfigurationError => e
28
- Rails.logger.warn "[JetStream Bridge] Configuration warning: #{e.message}"
30
+ # Validate configuration and setup environment-specific behavior
31
+ initializer 'jetstream_bridge.setup', after: :load_config_initializers do |app|
32
+ app.config.after_initialize do
33
+ # Validate configuration in development/test
34
+ if Rails.env.development? || Rails.env.test?
35
+ begin
36
+ JetstreamBridge.config.validate! if JetstreamBridge.config.destination_app
37
+ rescue JetstreamBridge::ConfigurationError => e
38
+ Rails.logger.warn "[JetStream Bridge] Configuration warning: #{e.message}"
39
+ end
29
40
  end
41
+
42
+ # Auto-enable test mode in test environment if NATS_URLS not set
43
+ if Rails.env.test? && ENV['NATS_URLS'].blank? && !(defined?(JetstreamBridge::TestHelpers) &&
44
+ JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
45
+ JetstreamBridge::TestHelpers.test_mode?)
46
+ Rails.logger.info '[JetStream Bridge] Auto-enabling test mode (NATS_URLS not set)'
47
+ require_relative 'test_helpers'
48
+ JetstreamBridge::TestHelpers.enable_test_mode!
49
+ end
50
+
51
+ # Log helpful connection info in development
52
+ if Rails.env.development? && JetstreamBridge.connected?
53
+ conn_state = JetstreamBridge::Connection.instance.state
54
+ Rails.logger.info "[JetStream Bridge] Connection state: #{conn_state}"
55
+ Rails.logger.info "[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}"
56
+ Rails.logger.info "[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}"
57
+ Rails.logger.info "[JetStream Bridge] Publishing to: #{JetstreamBridge.config.source_subject}"
58
+ Rails.logger.info "[JetStream Bridge] Consuming from: #{JetstreamBridge.config.destination_subject}"
59
+ end
60
+ rescue StandardError => e
61
+ # Don't fail app initialization for logging errors
62
+ Rails.logger.debug "[JetStream Bridge] Setup logging skipped: #{e.message}"
63
+ end
64
+ end
65
+
66
+ # Register shutdown hook for graceful cleanup
67
+ # This runs when Rails shuts down (Ctrl+C, SIGTERM, etc.)
68
+ config.after_initialize do
69
+ at_exit do
70
+ JetstreamBridge.shutdown!
30
71
  end
31
72
  end
32
73
 
@@ -34,6 +75,7 @@ module JetstreamBridge
34
75
  console do
35
76
  Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
36
77
  Rails.logger.info '[JetStream Bridge] Use JetstreamBridge.health_check to check status'
78
+ Rails.logger.info '[JetStream Bridge] Use JetstreamBridge.shutdown! to gracefully disconnect'
37
79
  end
38
80
 
39
81
  # Load rake tasks
@@ -0,0 +1,524 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ module TestHelpers
5
+ # In-memory mock for NATS JetStream connection
6
+ # Simulates the NATS::IO::Client and JetStream API without requiring a real server
7
+ module MockNats
8
+ class MockConnection
9
+ attr_reader :connected_at, :callbacks
10
+
11
+ def initialize
12
+ @connected = false
13
+ @connected_at = nil
14
+ @callbacks = { reconnect: [], disconnect: [], error: [] }
15
+ # Use global storage to ensure persistence across test helper calls
16
+ @jetstream = MockJetStream.new(MockNats.storage)
17
+ end
18
+
19
+ def connect(_urls = nil, **_options)
20
+ @connected = true
21
+ @connected_at = Time.now
22
+ self
23
+ end
24
+
25
+ def jetstream
26
+ raise NATS::IO::NoRespondersError, 'JetStream not available' unless @connected
27
+
28
+ @jetstream
29
+ end
30
+
31
+ def connected?
32
+ @connected
33
+ end
34
+
35
+ def on_reconnect(&block)
36
+ @callbacks[:reconnect] << block if block
37
+ end
38
+
39
+ def on_disconnect(&block)
40
+ @callbacks[:disconnect] << block if block
41
+ end
42
+
43
+ def on_error(&block)
44
+ @callbacks[:error] << block if block
45
+ end
46
+
47
+ def rtt
48
+ 0.001 # 1ms simulated round-trip time
49
+ end
50
+
51
+ # Simulate JetStream API requests
52
+ # Used by overlap_guard.rb for stream management operations
53
+ def request(subject, _payload, timeout: 1)
54
+ raise NATS::IO::NoRespondersError, 'Not connected' unless @connected
55
+
56
+ # Parse the API request
57
+ response_data = case subject
58
+ when '$JS.API.STREAM.NAMES'
59
+ # Return list of stream names
60
+ stream_names = @jetstream.storage.streams.keys.map { |name| { 'name' => name } }
61
+ {
62
+ 'type' => 'io.nats.jetstream.api.v1.stream_names_response',
63
+ 'total' => stream_names.size,
64
+ 'offset' => 0,
65
+ 'limit' => 1024,
66
+ 'streams' => stream_names
67
+ }
68
+ when /^\$JS\.API\.STREAM\.INFO\.(.+)$/
69
+ # Return stream info for specific stream
70
+ stream_name = ::Regexp.last_match(1)
71
+ stream = @jetstream.storage.find_stream(stream_name)
72
+ if stream
73
+ info = stream.info
74
+ {
75
+ 'type' => 'io.nats.jetstream.api.v1.stream_info_response',
76
+ 'config' => {
77
+ 'name' => info.config.name,
78
+ 'subjects' => info.config.subjects
79
+ }
80
+ }
81
+ else
82
+ {
83
+ 'error' => {
84
+ 'code' => 404,
85
+ 'description' => 'stream not found'
86
+ }
87
+ }
88
+ end
89
+ else
90
+ # Generic response for unknown API calls
91
+ { 'type' => 'io.nats.jetstream.api.v1.response' }
92
+ end
93
+
94
+ # Return a mock message object with the response data
95
+ MockApiResponse.new(Oj.dump(response_data, mode: :compat))
96
+ end
97
+
98
+ def close
99
+ @connected = false
100
+ @callbacks[:disconnect].each(&:call)
101
+ end
102
+
103
+ # Test helpers for simulating connection events
104
+ def simulate_disconnect!
105
+ @connected = false
106
+ @callbacks[:disconnect].each(&:call)
107
+ end
108
+
109
+ def simulate_reconnect!
110
+ @connected = true
111
+ @callbacks[:reconnect].each(&:call)
112
+ end
113
+
114
+ def simulate_error!(error)
115
+ @callbacks[:error].each { |cb| cb.call(error) }
116
+ end
117
+ end
118
+
119
+ class MockJetStream
120
+ attr_reader :storage
121
+
122
+ def initialize(storage = nil)
123
+ @storage = storage || MockNats.storage
124
+ end
125
+
126
+ def account_info
127
+ OpenStruct.new(
128
+ memory: 1024 * 1024 * 100,
129
+ storage: 1024 * 1024 * 1000,
130
+ streams: @storage.streams.count,
131
+ consumers: @storage.consumers.count
132
+ )
133
+ end
134
+
135
+ def publish(subject, data, header: {})
136
+ @storage.publish(subject, data, header)
137
+ end
138
+
139
+ def pull_subscribe(subject, durable_name, **options)
140
+ @storage.create_subscription(subject, durable_name, options)
141
+ end
142
+
143
+ def consumer_info(stream_name, durable_name)
144
+ consumer = @storage.find_consumer(stream_name, durable_name)
145
+ raise NATS::JetStream::Error, 'consumer not found' unless consumer
146
+
147
+ consumer.info
148
+ end
149
+
150
+ def stream_info(stream_name)
151
+ stream = @storage.find_stream(stream_name)
152
+ raise NATS::JetStream::Error, 'stream not found' unless stream
153
+
154
+ stream.info
155
+ end
156
+
157
+ def add_stream(config)
158
+ @storage.add_stream(config)
159
+ end
160
+
161
+ def delete_stream(name)
162
+ @storage.delete_stream(name)
163
+ end
164
+
165
+ def add_consumer(stream_name, **config)
166
+ @storage.add_consumer(stream_name, config)
167
+ end
168
+
169
+ def delete_consumer(stream_name, consumer_name)
170
+ @storage.delete_consumer(stream_name, consumer_name)
171
+ end
172
+ end
173
+
174
+ class InMemoryStorage
175
+ attr_reader :streams, :consumers, :messages, :subscriptions
176
+
177
+ def initialize
178
+ @streams = {}
179
+ @consumers = {}
180
+ @messages = []
181
+ @subscriptions = {}
182
+ @sequence_counter = 0
183
+ @mutex = Mutex.new
184
+ end
185
+
186
+ def publish(subject, data, header)
187
+ @mutex.synchronize do
188
+ event_id = header['nats-msg-id'] || SecureRandom.uuid
189
+
190
+ # Check for duplicate
191
+ duplicate = @messages.any? { |msg| msg[:header]['nats-msg-id'] == event_id }
192
+
193
+ unless duplicate
194
+ @sequence_counter += 1
195
+ @messages << {
196
+ subject: subject,
197
+ data: data,
198
+ header: header,
199
+ sequence: @sequence_counter,
200
+ timestamp: Time.now,
201
+ delivery_count: 0
202
+ }
203
+ end
204
+
205
+ MockAck.new(
206
+ duplicate: duplicate,
207
+ sequence: @sequence_counter,
208
+ stream: find_stream_for_subject(subject)&.name || 'mock-stream'
209
+ )
210
+ end
211
+ end
212
+
213
+ def create_subscription(subject, durable_name, options)
214
+ @mutex.synchronize do
215
+ stream_name = options[:stream] || find_stream_for_subject(subject)&.name || 'mock-stream'
216
+
217
+ subscription = MockSubscription.new(
218
+ subject: subject,
219
+ durable_name: durable_name,
220
+ storage: self,
221
+ stream_name: stream_name,
222
+ options: options
223
+ )
224
+
225
+ @subscriptions[durable_name] = subscription
226
+
227
+ # Register consumer
228
+ @consumers[durable_name] = MockConsumer.new(
229
+ name: durable_name,
230
+ stream: stream_name,
231
+ config: options
232
+ )
233
+
234
+ subscription
235
+ end
236
+ end
237
+
238
+ def fetch_messages(subject, durable_name, batch_size, _timeout)
239
+ @mutex.synchronize do
240
+ consumer = @consumers[durable_name]
241
+ stream_name = consumer&.stream || 'mock-stream'
242
+
243
+ # Find messages matching the subject
244
+ matching = @messages.select do |msg|
245
+ msg[:subject] == subject && msg[:delivery_count] < max_deliver_for(durable_name)
246
+ end
247
+
248
+ # Take up to batch_size messages
249
+ to_deliver = matching.first(batch_size)
250
+
251
+ # Increment delivery count and return MockMessage objects
252
+ to_deliver.map do |msg|
253
+ msg[:delivery_count] += 1
254
+
255
+ MockMessage.new(
256
+ subject: msg[:subject],
257
+ data: msg[:data],
258
+ header: msg[:header],
259
+ sequence: msg[:sequence],
260
+ stream: stream_name,
261
+ consumer: durable_name,
262
+ num_delivered: msg[:delivery_count],
263
+ storage: self,
264
+ message_ref: msg
265
+ )
266
+ end
267
+ end
268
+ end
269
+
270
+ def ack_message(message_ref)
271
+ @mutex.synchronize do
272
+ @messages.delete(message_ref)
273
+ end
274
+ end
275
+
276
+ def nak_message(_message_ref, delay: nil)
277
+ @mutex.synchronize do
278
+ # Message stays in queue for redelivery
279
+ # Note: delivery_count was already incremented during fetch
280
+ # We don't decrement it here as it represents actual delivery attempts
281
+ end
282
+ end
283
+
284
+ def term_message(message_ref)
285
+ @mutex.synchronize do
286
+ @messages.delete(message_ref)
287
+ end
288
+ end
289
+
290
+ def add_stream(config)
291
+ @mutex.synchronize do
292
+ name = config[:name] || config['name']
293
+ @streams[name] = MockStream.new(name, config)
294
+ end
295
+ end
296
+
297
+ def delete_stream(name)
298
+ @mutex.synchronize do
299
+ @streams.delete(name)
300
+ end
301
+ end
302
+
303
+ def find_stream(name)
304
+ @streams[name]
305
+ end
306
+
307
+ def find_stream_for_subject(_subject)
308
+ @streams.values.first # Simplified: return first stream
309
+ end
310
+
311
+ def find_consumer(stream_name, durable_name)
312
+ consumer = @consumers[durable_name]
313
+ return nil unless consumer
314
+ return nil unless consumer.stream == stream_name
315
+
316
+ consumer
317
+ end
318
+
319
+ def add_consumer(stream_name, config)
320
+ @mutex.synchronize do
321
+ durable_name = config[:durable_name] || config['durable_name']
322
+ @consumers[durable_name] = MockConsumer.new(
323
+ name: durable_name,
324
+ stream: stream_name,
325
+ config: config
326
+ )
327
+ end
328
+ end
329
+
330
+ def delete_consumer(stream_name, consumer_name)
331
+ @mutex.synchronize do
332
+ consumer = @consumers[consumer_name]
333
+ @consumers.delete(consumer_name) if consumer&.stream == stream_name
334
+ end
335
+ end
336
+
337
+ def reset!
338
+ @mutex.synchronize do
339
+ @streams.clear
340
+ @consumers.clear
341
+ @messages.clear
342
+ @subscriptions.clear
343
+ @sequence_counter = 0
344
+ end
345
+ end
346
+
347
+ private
348
+
349
+ def max_deliver_for(durable_name)
350
+ consumer = @consumers[durable_name]
351
+ return 5 unless consumer # Default
352
+
353
+ consumer.config[:max_deliver] || consumer.config['max_deliver'] || 5
354
+ end
355
+ end
356
+
357
+ class MockAck
358
+ attr_reader :sequence, :stream, :error
359
+
360
+ def initialize(duplicate:, sequence:, stream:)
361
+ @duplicate = duplicate
362
+ @sequence = sequence
363
+ @stream = stream
364
+ @error = nil
365
+ end
366
+
367
+ def duplicate?
368
+ @duplicate
369
+ end
370
+ end
371
+
372
+ class MockMessage
373
+ attr_reader :subject, :data, :header, :sequence, :stream, :consumer, :num_delivered
374
+
375
+ def initialize(subject:, data:, header:, sequence:, stream:, consumer:, num_delivered:, storage:, message_ref:)
376
+ @subject = subject
377
+ @data = data
378
+ @header = header
379
+ @sequence = sequence
380
+ @stream = stream
381
+ @consumer = consumer
382
+ @num_delivered = num_delivered
383
+ @storage = storage
384
+ @message_ref = message_ref
385
+ end
386
+
387
+ def metadata
388
+ OpenStruct.new(
389
+ sequence: OpenStruct.new(stream: @sequence, consumer: @sequence),
390
+ num_delivered: @num_delivered,
391
+ stream: @stream,
392
+ consumer: @consumer,
393
+ timestamp: Time.now
394
+ )
395
+ end
396
+
397
+ def ack
398
+ @storage.ack_message(@message_ref)
399
+ end
400
+
401
+ def nak(in_progress_duration: nil)
402
+ @storage.nak_message(@message_ref, delay: in_progress_duration)
403
+ end
404
+
405
+ def term
406
+ @storage.term_message(@message_ref)
407
+ end
408
+ end
409
+
410
+ class MockSubscription
411
+ attr_reader :subject, :durable_name, :stream_name
412
+
413
+ def initialize(subject:, durable_name:, storage:, stream_name:, options:)
414
+ @subject = subject
415
+ @durable_name = durable_name
416
+ @storage = storage
417
+ @stream_name = stream_name
418
+ @options = options
419
+ @unsubscribed = false
420
+ end
421
+
422
+ def fetch(batch_size, timeout: 5)
423
+ raise NATS::JetStream::Error, 'consumer not found' if @unsubscribed
424
+
425
+ @storage.fetch_messages(@subject, @durable_name, batch_size, timeout)
426
+ rescue StandardError => e
427
+ raise NATS::IO::Timeout if timeout && e.is_a?(Timeout::Error)
428
+
429
+ raise
430
+ end
431
+
432
+ def unsubscribe
433
+ @unsubscribed = true
434
+ end
435
+ end
436
+
437
+ class MockStream
438
+ attr_reader :name, :config
439
+
440
+ def initialize(name, config)
441
+ @name = name
442
+ @config = config
443
+ end
444
+
445
+ def info
446
+ OpenStruct.new(
447
+ config: OpenStruct.new(
448
+ name: @name,
449
+ subjects: @config[:subjects] || [@name],
450
+ retention: @config[:retention] || 'limits',
451
+ max_consumers: @config[:max_consumers] || -1,
452
+ max_msgs: @config[:max_msgs] || -1,
453
+ max_bytes: @config[:max_bytes] || -1,
454
+ discard: @config[:discard] || 'old',
455
+ max_age: @config[:max_age] || 0,
456
+ max_msgs_per_subject: @config[:max_msgs_per_subject] || -1,
457
+ max_msg_size: @config[:max_msg_size] || -1,
458
+ storage: @config[:storage] || 'file',
459
+ num_replicas: @config[:num_replicas] || 1
460
+ ),
461
+ state: OpenStruct.new(
462
+ messages: 0,
463
+ bytes: 0,
464
+ first_seq: 1,
465
+ last_seq: 0,
466
+ consumer_count: 0
467
+ )
468
+ )
469
+ end
470
+ end
471
+
472
+ class MockConsumer
473
+ attr_reader :name, :stream, :config
474
+
475
+ def initialize(name:, stream:, config:)
476
+ @name = name
477
+ @stream = stream
478
+ @config = config
479
+ end
480
+
481
+ def info
482
+ OpenStruct.new(
483
+ name: @name,
484
+ stream_name: @stream,
485
+ config: OpenStruct.new(
486
+ durable_name: @name,
487
+ ack_policy: @config[:ack_policy] || 'explicit',
488
+ max_deliver: @config[:max_deliver] || 5,
489
+ ack_wait: @config[:ack_wait] || 30_000_000_000,
490
+ filter_subject: @config[:filter_subject] || '',
491
+ replay_policy: @config[:replay_policy] || 'instant'
492
+ ),
493
+ num_pending: 0,
494
+ num_delivered: 0
495
+ )
496
+ end
497
+ end
498
+
499
+ # Mock API response message
500
+ # Simulates NATS::Msg for JetStream API responses
501
+ class MockApiResponse
502
+ attr_reader :data
503
+
504
+ def initialize(data)
505
+ @data = data
506
+ end
507
+ end
508
+
509
+ # Factory method to create a mock connection
510
+ def self.create_mock_connection
511
+ MockConnection.new
512
+ end
513
+
514
+ # Global storage accessor for testing
515
+ def self.storage
516
+ @storage ||= InMemoryStorage.new
517
+ end
518
+
519
+ def self.reset!
520
+ @storage&.reset!
521
+ end
522
+ end
523
+ end
524
+ end