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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +338 -87
- data/README.md +3 -13
- data/docs/GETTING_STARTED.md +8 -12
- data/docs/PRODUCTION.md +13 -35
- data/docs/RESTRICTED_PERMISSIONS.md +525 -0
- data/docs/TESTING.md +33 -22
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
- data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
- data/lib/jetstream_bridge/core/config.rb +32 -161
- data/lib/jetstream_bridge/core/connection.rb +508 -0
- data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
- data/lib/jetstream_bridge/core.rb +2 -0
- data/lib/jetstream_bridge/models/subject.rb +15 -23
- data/lib/jetstream_bridge/provisioner.rb +67 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
- data/lib/jetstream_bridge/rails/integration.rb +5 -8
- data/lib/jetstream_bridge/rails/railtie.rb +3 -4
- data/lib/jetstream_bridge/tasks/install.rake +59 -12
- data/lib/jetstream_bridge/topology/topology.rb +1 -6
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +345 -202
- metadata +8 -8
- data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
- data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
- data/lib/jetstream_bridge/core/health_checker.rb +0 -184
- data/lib/jetstream_bridge/facade.rb +0 -212
- 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: ['
|
|
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
|
-
|
|
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
|
|
299
|
+
expect(result).to be_publish_success
|
|
290
300
|
expect(result.event_id).to be_present
|
|
291
|
-
|
|
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
|
|
309
|
+
### Full Consuming Flow
|
|
300
310
|
|
|
301
311
|
```ruby
|
|
302
|
-
it 'consumes
|
|
303
|
-
|
|
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
|
-
'
|
|
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. **
|
|
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": "
|
|
45
|
-
"subjects": ["
|
|
44
|
+
"name": "jetstream-bridge-stream",
|
|
45
|
+
"subjects": ["app1.sync.app2"],
|
|
46
46
|
"messages": 42
|
|
47
47
|
},
|
|
48
48
|
"config": {
|
|
49
|
-
"
|
|
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
|
|
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
|
|
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
|
|
83
|
+
# consumer = JetstreamBridge::Consumer.new(handler)
|
|
90
84
|
#
|
|
91
85
|
# @example With block
|
|
92
|
-
# consumer = JetstreamBridge::Consumer.new
|
|
86
|
+
# consumer = JetstreamBridge::Consumer.new do |event|
|
|
93
87
|
# UserEventHandler.process(event)
|
|
94
88
|
# end
|
|
95
89
|
#
|
|
96
|
-
|
|
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 ||
|
|
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,
|
|
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
|
|
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=#{
|
|
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
|
-
@
|
|
203
|
-
|
|
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!
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
msgs = @psub.fetch(@batch_size, 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.
|
|
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
|
)
|