jetstream_bridge 4.5.0 → 4.5.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +338 -87
  3. data/README.md +3 -13
  4. data/docs/GETTING_STARTED.md +8 -12
  5. data/docs/PRODUCTION.md +13 -35
  6. data/docs/RESTRICTED_PERMISSIONS.md +525 -0
  7. data/docs/TESTING.md +33 -22
  8. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
  10. data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
  11. data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
  12. data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
  13. data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
  14. data/lib/jetstream_bridge/core/config.rb +32 -161
  15. data/lib/jetstream_bridge/core/connection.rb +508 -0
  16. data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
  17. data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
  18. data/lib/jetstream_bridge/core.rb +2 -0
  19. data/lib/jetstream_bridge/models/subject.rb +15 -23
  20. data/lib/jetstream_bridge/provisioner.rb +67 -0
  21. data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
  22. data/lib/jetstream_bridge/rails/integration.rb +5 -8
  23. data/lib/jetstream_bridge/rails/railtie.rb +3 -4
  24. data/lib/jetstream_bridge/tasks/install.rake +59 -12
  25. data/lib/jetstream_bridge/topology/topology.rb +1 -6
  26. data/lib/jetstream_bridge/version.rb +1 -1
  27. data/lib/jetstream_bridge.rb +345 -202
  28. metadata +8 -8
  29. data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
  30. data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
  31. data/lib/jetstream_bridge/core/health_checker.rb +0 -184
  32. data/lib/jetstream_bridge/facade.rb +0 -212
  33. data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +0 -110
data/docs/TESTING.md CHANGED
@@ -28,28 +28,31 @@ RSpec.describe MyService do
28
28
  include JetstreamBridge::TestHelpers::Matchers
29
29
 
30
30
  before do
31
- # Reset state
31
+ # Reset singleton to ensure clean state
32
+ JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
32
33
  JetstreamBridge.reset!
33
34
 
34
35
  # Enable test mode - automatically sets up mock NATS
35
36
  JetstreamBridge::TestHelpers.enable_test_mode!
36
37
 
37
38
  JetstreamBridge.configure do |config|
39
+ config.stream_name = 'jetstream-bridge-stream'
38
40
  config.app_name = 'my_app'
39
41
  config.destination_app = 'worker'
40
- config.stream_name = 'test-stream'
41
42
  end
42
43
 
43
- # Setup mock stream
44
+ # Setup mock stream and stub topology
44
45
  mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
45
46
  mock_jts.add_stream(
46
- name: 'test-stream',
47
- subjects: ['my_app.sync.worker', 'worker.sync.my_app']
47
+ name: 'test-jetstream-bridge-stream',
48
+ subjects: ['test.>']
48
49
  )
50
+ allow(JetstreamBridge::Topology).to receive(:ensure!)
49
51
  end
50
52
 
51
53
  after do
52
54
  JetstreamBridge::TestHelpers.reset_test_mode!
55
+ JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
53
56
  end
54
57
 
55
58
  it 'publishes events through the full stack' do
@@ -263,20 +266,27 @@ end.to raise_error(NATS::JetStream::Error, 'consumer not found')
263
266
  ```ruby
264
267
  before do
265
268
  JetstreamBridge.reset!
266
- JetstreamBridge::TestHelpers.enable_test_mode!
269
+
270
+ mock_conn = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
271
+ mock_jts = mock_conn.jetstream
272
+
273
+ allow(NATS::IO::Client).to receive(:new).and_return(mock_conn)
274
+ allow(JetstreamBridge::Connection).to receive(:connect!).and_call_original
275
+
276
+ # Setup stream
277
+ mock_jts.add_stream(
278
+ name: 'test-jetstream-bridge-stream',
279
+ subjects: ['test.>']
280
+ )
281
+
282
+ # Allow topology check to succeed
283
+ allow(JetstreamBridge::Topology).to receive(:ensure!)
267
284
 
268
285
  JetstreamBridge.configure do |config|
286
+ config.stream_name = 'jetstream-bridge-stream'
269
287
  config.app_name = 'api'
270
288
  config.destination_app = 'worker'
271
- config.stream_name = 'test-stream'
272
289
  end
273
-
274
- # Setup mock stream
275
- mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
276
- mock_jts.add_stream(
277
- name: 'test-stream',
278
- subjects: ['api.sync.worker', 'worker.sync.api']
279
- )
280
290
  end
281
291
 
282
292
  it 'publishes through JetstreamBridge' do
@@ -286,9 +296,9 @@ it 'publishes through JetstreamBridge' do
286
296
  payload: { id: 1, name: 'Test' }
287
297
  )
288
298
 
289
- expect(result.success?).to be true
299
+ expect(result).to be_publish_success
290
300
  expect(result.event_id).to be_present
291
- expect(result.subject).to eq('api.sync.worker')
301
+ expect(result.subject).to eq('api.sync.worker')
292
302
 
293
303
  # Verify in storage
294
304
  storage = JetstreamBridge::TestHelpers.mock_storage
@@ -296,11 +306,12 @@ it 'publishes through JetstreamBridge' do
296
306
  end
297
307
  ```
298
308
 
299
- ### Full Consumer Flow
309
+ ### Full Consuming Flow
300
310
 
301
311
  ```ruby
302
- it 'consumes messages through JetstreamBridge' do
303
- mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
312
+ it 'consumes through JetstreamBridge' do
313
+ mock_conn = JetstreamBridge::TestHelpers.mock_connection
314
+ mock_jts = mock_conn.jetstream
304
315
 
305
316
  # Publish message to destination subject
306
317
  mock_jts.publish(
@@ -328,8 +339,8 @@ it 'consumes messages through JetstreamBridge' do
328
339
  # Mock subscription
329
340
  subscription = mock_jts.pull_subscribe(
330
341
  'worker.sync.api',
331
- 'api-workers',
332
- stream: 'test-stream'
342
+ 'test-consumer',
343
+ stream: 'test-jetstream-bridge-stream'
333
344
  )
334
345
 
335
346
  allow_any_instance_of(JetstreamBridge::SubscriptionManager)
@@ -380,7 +391,7 @@ storage.reset!
380
391
  3. **Test both success and failure paths**: Use the mock to simulate errors
381
392
  4. **Verify message content**: Check that envelopes are correctly formatted
382
393
  5. **Test idempotency**: Verify duplicate detection and redelivery behavior
383
- 6. **Set stream_name**: Always configure `stream_name` in your tests
394
+ 6. **Mock topology setup**: Remember to stub `JetstreamBridge::Topology.ensure!`
384
395
 
385
396
  ## Examples
386
397
 
@@ -41,12 +41,12 @@ module JetstreamBridge
41
41
  "connected_at": "2025-11-22T20:00:00Z",
42
42
  "stream": {
43
43
  "exists": true,
44
- "name": "development-jetstream-bridge-stream",
45
- "subjects": ["dev.app1.sync.app2"],
44
+ "name": "jetstream-bridge-stream",
45
+ "subjects": ["app1.sync.app2"],
46
46
  "messages": 42
47
47
  },
48
48
  "config": {
49
- "env": "development",
49
+ "stream_name": "jetstream-bridge-stream",
50
50
  "app_name": "my_app",
51
51
  "destination_app": "other_app"
52
52
  },
@@ -13,6 +13,9 @@ JetstreamBridge.configure do |config|
13
13
  # NATS server URLs (comma-separated for cluster)
14
14
  config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
15
15
 
16
+ # Stream name (required) - managed separately from runtime credentials
17
+ config.stream_name = ENV.fetch('JETSTREAM_STREAM_NAME', 'jetstream-bridge-stream')
18
+
16
19
  # Application name (used in subject routing)
17
20
  config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
18
21
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'oj'
4
4
  require 'securerandom'
5
+ require_relative '../core/connection'
5
6
  require_relative '../core/duration'
6
7
  require_relative '../core/logging'
7
8
  require_relative '../core/config'
@@ -9,7 +10,6 @@ require_relative '../core/model_utils'
9
10
  require_relative 'message_processor'
10
11
  require_relative 'subscription_manager'
11
12
  require_relative 'inbox/inbox_processor'
12
- require_relative 'health_monitor'
13
13
 
14
14
  module JetstreamBridge
15
15
  # Subscribes to destination subject and processes messages via a pull durable consumer.
@@ -48,14 +48,6 @@ module JetstreamBridge
48
48
  IDLE_SLEEP_SECS = 0.05
49
49
  # Maximum sleep duration during idle periods (seconds)
50
50
  MAX_IDLE_BACKOFF_SECS = 1.0
51
- # Maximum number of batches to drain during shutdown
52
- MAX_DRAIN_BATCHES = 5
53
- # Drain timeout per batch in seconds
54
- DRAIN_BATCH_TIMEOUT = 1
55
- # Minimum reconnect backoff in seconds
56
- MIN_RECONNECT_BACKOFF = 0.1
57
- # Maximum reconnect backoff in seconds
58
- MAX_RECONNECT_BACKOFF = 30.0
59
51
 
60
52
  # Alias middleware classes for easier access
61
53
  MiddlewareChain = ConsumerMiddleware::MiddlewareChain
@@ -72,51 +64,60 @@ module JetstreamBridge
72
64
  # @return [MiddlewareChain] Middleware chain for processing
73
65
  attr_reader :middleware_chain
74
66
 
75
- # Initialize a new Consumer instance with dependency injection.
67
+ # Initialize a new Consumer instance.
76
68
  #
77
69
  # @param handler [Proc, #call, nil] Message handler that processes events.
78
70
  # Must respond to #call(event) or #call(event, subject, deliveries).
79
- # @param connection [NATS::JetStream::JS] JetStream connection
80
- # @param config [Config] Configuration instance
81
71
  # @param durable_name [String, nil] Optional durable consumer name override.
72
+ # Defaults to config.durable_name.
82
73
  # @param batch_size [Integer, nil] Number of messages to fetch per batch.
74
+ # Defaults to DEFAULT_BATCH_SIZE (25).
83
75
  # @yield [event] Optional block as handler. Receives Models::Event object.
84
76
  #
85
- # @raise [ArgumentError] If required dependencies are missing
77
+ # @raise [ArgumentError] If neither handler nor block provided
78
+ # @raise [ArgumentError] If destination_app not configured
79
+ # @raise [ConnectionError] If unable to connect to NATS
86
80
  #
87
81
  # @example With proc handler
88
82
  # handler = ->(event) { puts "Received: #{event.type}" }
89
- # consumer = JetstreamBridge::Consumer.new(handler, connection: jts, config: config)
83
+ # consumer = JetstreamBridge::Consumer.new(handler)
90
84
  #
91
85
  # @example With block
92
- # consumer = JetstreamBridge::Consumer.new(connection: jts, config: config) do |event|
86
+ # consumer = JetstreamBridge::Consumer.new do |event|
93
87
  # UserEventHandler.process(event)
94
88
  # end
95
89
  #
96
- def initialize(handler = nil, connection:, config:, durable_name: nil, batch_size: nil, &block)
90
+ # @example With custom configuration
91
+ # consumer = JetstreamBridge::Consumer.new(
92
+ # handler,
93
+ # durable_name: "my-consumer",
94
+ # batch_size: 10
95
+ # )
96
+ #
97
+ def initialize(handler = nil, durable_name: nil, batch_size: nil, &block)
97
98
  @handler = handler || block
98
99
  raise ArgumentError, 'handler or block required' unless @handler
99
- raise ArgumentError, 'connection is required' unless connection
100
- raise ArgumentError, 'config is required' unless config
101
-
102
- @jts = connection
103
- @config = config
104
100
 
105
101
  @batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
106
- @durable = durable_name || @config.durable_name
102
+ @durable = durable_name || JetstreamBridge.config.durable_name
107
103
  @idle_backoff = IDLE_SLEEP_SECS
108
104
  @reconnect_attempts = 0
109
105
  @running = true
110
106
  @shutdown_requested = false
107
+ @start_time = Time.now
108
+ @iterations = 0
109
+ @last_health_check = Time.now
110
+ # Use existing connection (should already be established)
111
+ @jts = Connection.jetstream
112
+ raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
111
113
 
112
114
  @middleware_chain = MiddlewareChain.new
113
- @health_monitor = ConsumerHealthMonitor.new(@durable)
114
115
 
115
116
  ensure_destination_app_configured!
116
117
 
117
- @sub_mgr = SubscriptionManager.new(@jts, @durable, @config)
118
+ @sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
118
119
  @processor = MessageProcessor.new(@jts, @handler, middleware_chain: @middleware_chain)
119
- @inbox_proc = InboxProcessor.new(@processor) if @config.use_inbox
120
+ @inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
120
121
 
121
122
  ensure_subscription!
122
123
  setup_signal_handlers
@@ -192,15 +193,17 @@ module JetstreamBridge
192
193
  #
193
194
  def run!
194
195
  Logging.info(
195
- "Consumer #{@durable} started (batch=#{@batch_size}, dest=#{@config.destination_subject})…",
196
+ "Consumer #{@durable} started (batch=#{@batch_size}, dest=#{JetstreamBridge.config.destination_subject})…",
196
197
  tag: 'JetstreamBridge::Consumer'
197
198
  )
198
199
  while @running
199
200
  processed = process_batch
200
201
  idle_sleep(processed)
201
202
 
202
- @health_monitor.increment_iterations
203
- @health_monitor.check_health_if_due
203
+ @iterations += 1
204
+
205
+ # Periodic health checks every 10 minutes (600 seconds)
206
+ perform_health_check_if_due
204
207
  end
205
208
 
206
209
  # Drain in-flight messages before exiting
@@ -241,17 +244,16 @@ module JetstreamBridge
241
244
 
242
245
  private
243
246
 
244
- def ensure_destination_app_configured!
245
- return unless @config.destination_app.to_s.empty?
246
-
247
- raise ArgumentError, 'destination_app must be configured'
248
- end
249
-
250
247
  def ensure_subscription!
251
- @sub_mgr.ensure_consumer! unless @config.disable_js_api
248
+ @sub_mgr.ensure_consumer!
252
249
  @psub = @sub_mgr.subscribe!
253
250
  end
254
251
 
252
+ def ensure_destination_app_configured!
253
+ # Use subject builder to enforce required components and align with existing validation messages.
254
+ JetstreamBridge.config.destination_subject
255
+ end
256
+
255
257
  # Returns number of messages processed; 0 on timeout/idle or after recovery.
256
258
  def process_batch
257
259
  msgs = fetch_messages
@@ -311,8 +313,10 @@ module JetstreamBridge
311
313
  end
312
314
 
313
315
  def calculate_reconnect_backoff(attempt)
314
- # Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to max
315
- [MIN_RECONNECT_BACKOFF * (2**(attempt - 1)), MAX_RECONNECT_BACKOFF].min
316
+ # Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to 30s max
317
+ base_delay = 0.1
318
+ max_delay = 30.0
319
+ [base_delay * (2**(attempt - 1)), max_delay].min
316
320
  end
317
321
 
318
322
  def recoverable_consumer_error?(error)
@@ -352,13 +356,70 @@ module JetstreamBridge
352
356
  Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
353
357
  end
354
358
 
359
+ def perform_health_check_if_due
360
+ now = Time.now
361
+ time_since_check = now - @last_health_check
362
+
363
+ return unless time_since_check >= 600 # 10 minutes
364
+
365
+ @last_health_check = now
366
+ uptime = now - @start_time
367
+ memory_mb = memory_usage_mb
368
+
369
+ Logging.info(
370
+ "Consumer health: iterations=#{@iterations}, " \
371
+ "memory=#{memory_mb}MB, uptime=#{uptime.round}s",
372
+ tag: 'JetstreamBridge::Consumer'
373
+ )
374
+
375
+ # Warn if memory usage is high (over 1GB)
376
+ if memory_mb > 1000
377
+ Logging.warn(
378
+ "High memory usage detected: #{memory_mb}MB",
379
+ tag: 'JetstreamBridge::Consumer'
380
+ )
381
+ end
382
+
383
+ # Suggest GC if heap is growing significantly
384
+ suggest_gc_if_needed
385
+ rescue StandardError => e
386
+ Logging.debug(
387
+ "Health check failed: #{e.class} #{e.message}",
388
+ tag: 'JetstreamBridge::Consumer'
389
+ )
390
+ end
391
+
392
+ def memory_usage_mb
393
+ # Get memory usage from OS (works on Linux/macOS)
394
+ rss_kb = `ps -o rss= -p #{Process.pid}`.to_i
395
+ rss_kb / 1024.0
396
+ rescue StandardError
397
+ 0.0
398
+ end
399
+
400
+ def suggest_gc_if_needed
401
+ # Suggest GC if heap has many live slots (Ruby-specific optimization)
402
+ return unless defined?(GC) && GC.respond_to?(:stat)
403
+
404
+ stats = GC.stat
405
+ heap_live_slots = stats[:heap_live_slots] || stats['heap_live_slots'] || 0
406
+
407
+ # Suggest GC if we have over 100k live objects
408
+ GC.start if heap_live_slots > 100_000
409
+ rescue StandardError => e
410
+ Logging.debug(
411
+ "GC check failed: #{e.class} #{e.message}",
412
+ tag: 'JetstreamBridge::Consumer'
413
+ )
414
+ end
415
+
355
416
  def drain_inflight_messages
356
417
  return unless @psub
357
418
 
358
419
  Logging.info('Draining in-flight messages...', tag: 'JetstreamBridge::Consumer')
359
420
  # Process any pending messages with a short timeout
360
- MAX_DRAIN_BATCHES.times do
361
- msgs = @psub.fetch(@batch_size, timeout: DRAIN_BATCH_TIMEOUT)
421
+ 5.times do
422
+ msgs = @psub.fetch(@batch_size, timeout: 1)
362
423
  break if msgs.nil? || msgs.empty?
363
424
 
364
425
  msgs.each { |m| process_one(m) }
@@ -198,7 +198,7 @@ module JetstreamBridge
198
198
 
199
199
  def log_ack(result)
200
200
  ctx = result.ctx
201
- Logging.debug(
201
+ Logging.info(
202
202
  "ACK event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} deliveries=#{ctx&.deliveries}",
203
203
  tag: 'JetstreamBridge::Consumer'
204
204
  )