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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +28 -3
- data/lib/jetstream_bridge/consumer/consumer.rb +87 -3
- data/lib/jetstream_bridge/core/config.rb +27 -4
- data/lib/jetstream_bridge/core/connection.rb +145 -13
- data/lib/jetstream_bridge/core/debug_helper.rb +24 -12
- data/lib/jetstream_bridge/models/inbox_event.rb +11 -5
- data/lib/jetstream_bridge/publisher/publisher.rb +5 -2
- data/lib/jetstream_bridge/railtie.rb +49 -7
- data/lib/jetstream_bridge/test_helpers/mock_nats.rb +524 -0
- data/lib/jetstream_bridge/test_helpers.rb +221 -2
- data/lib/jetstream_bridge/topology/overlap_guard.rb +50 -1
- data/lib/jetstream_bridge/topology/stream.rb +14 -6
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +170 -14
- metadata +12 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require_relative 'test_helpers/mock_nats'
|
|
4
5
|
|
|
5
6
|
module JetstreamBridge
|
|
6
7
|
# Test helpers for easier testing of JetStream Bridge integrations
|
|
@@ -34,13 +35,50 @@ module JetstreamBridge
|
|
|
34
35
|
#
|
|
35
36
|
module TestHelpers
|
|
36
37
|
class << self
|
|
37
|
-
#
|
|
38
|
+
# Auto-configure test helpers when RSpec is detected
|
|
38
39
|
#
|
|
40
|
+
# This method is called automatically when test_helpers.rb is required.
|
|
41
|
+
# It sets up RSpec configuration to enable test mode for tests tagged with :jetstream.
|
|
42
|
+
#
|
|
43
|
+
# @return [void]
|
|
44
|
+
def auto_configure!
|
|
45
|
+
return unless defined?(RSpec)
|
|
46
|
+
return if @configured
|
|
47
|
+
|
|
48
|
+
RSpec.configure do |config|
|
|
49
|
+
config.include JetstreamBridge::TestHelpers
|
|
50
|
+
config.include JetstreamBridge::TestHelpers::Matchers
|
|
51
|
+
|
|
52
|
+
config.before(:each, :jetstream) do
|
|
53
|
+
JetstreamBridge::TestHelpers.enable_test_mode!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
config.after(:each, :jetstream) do
|
|
57
|
+
JetstreamBridge::TestHelpers.reset_test_mode!
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@configured = true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if auto-configuration has been applied
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def configured?
|
|
68
|
+
@configured ||= false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Enable test mode with in-memory event capture and mock NATS connection
|
|
72
|
+
#
|
|
73
|
+
# @param use_mock_nats [Boolean] Whether to use mock NATS connection (default: true)
|
|
39
74
|
# @return [void]
|
|
40
|
-
def enable_test_mode!
|
|
75
|
+
def enable_test_mode!(use_mock_nats: true)
|
|
41
76
|
@test_mode = true
|
|
42
77
|
@published_events = []
|
|
43
78
|
@consumed_events = []
|
|
79
|
+
@mock_nats_enabled = use_mock_nats
|
|
80
|
+
|
|
81
|
+
setup_mock_nats if use_mock_nats
|
|
44
82
|
end
|
|
45
83
|
|
|
46
84
|
# Reset test mode and clear captured events
|
|
@@ -50,6 +88,44 @@ module JetstreamBridge
|
|
|
50
88
|
@test_mode = false
|
|
51
89
|
@published_events = []
|
|
52
90
|
@consumed_events = []
|
|
91
|
+
|
|
92
|
+
teardown_mock_nats if @mock_nats_enabled
|
|
93
|
+
@mock_nats_enabled = false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Setup mock NATS connection
|
|
97
|
+
#
|
|
98
|
+
# @return [void]
|
|
99
|
+
def setup_mock_nats
|
|
100
|
+
MockNats.reset!
|
|
101
|
+
@mock_connection = MockNats.create_mock_connection
|
|
102
|
+
@mock_connection.connect
|
|
103
|
+
|
|
104
|
+
# Store the mock for Connection to use
|
|
105
|
+
JetstreamBridge.instance_variable_set(:@mock_nats_client, @mock_connection)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Teardown mock NATS connection
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
def teardown_mock_nats
|
|
112
|
+
MockNats.reset!
|
|
113
|
+
@mock_connection = nil
|
|
114
|
+
return unless JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
|
|
115
|
+
|
|
116
|
+
JetstreamBridge.remove_instance_variable(:@mock_nats_client)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Get the current mock connection
|
|
120
|
+
#
|
|
121
|
+
# @return [MockNats::MockConnection, nil]
|
|
122
|
+
attr_reader :mock_connection
|
|
123
|
+
|
|
124
|
+
# Get the mock storage for direct access in tests
|
|
125
|
+
#
|
|
126
|
+
# @return [MockNats::InMemoryStorage]
|
|
127
|
+
def mock_storage
|
|
128
|
+
MockNats.storage
|
|
53
129
|
end
|
|
54
130
|
|
|
55
131
|
# Check if test mode is enabled
|
|
@@ -271,5 +347,148 @@ module JetstreamBridge
|
|
|
271
347
|
end
|
|
272
348
|
end
|
|
273
349
|
end
|
|
350
|
+
|
|
351
|
+
# Test fixtures for common event scenarios
|
|
352
|
+
#
|
|
353
|
+
# @example Create a user.created event
|
|
354
|
+
# event = JetstreamBridge::TestHelpers::Fixtures.user_created_event(id: 123)
|
|
355
|
+
#
|
|
356
|
+
module Fixtures
|
|
357
|
+
# Build a user.created event
|
|
358
|
+
#
|
|
359
|
+
# @param attrs [Hash] Event attributes
|
|
360
|
+
# @option attrs [Integer] :id User ID
|
|
361
|
+
# @option attrs [String] :email User email
|
|
362
|
+
# @option attrs [String] :name User name
|
|
363
|
+
# @option attrs [Hash] :payload Additional payload data
|
|
364
|
+
# @return [Models::Event] Event object
|
|
365
|
+
def self.user_created_event(attrs = {})
|
|
366
|
+
build_jetstream_event(
|
|
367
|
+
event_type: 'user.created',
|
|
368
|
+
payload: {
|
|
369
|
+
id: attrs[:id] || 1,
|
|
370
|
+
email: attrs[:email] || 'test@example.com',
|
|
371
|
+
name: attrs[:name] || 'Test User'
|
|
372
|
+
}.merge(attrs[:payload] || {})
|
|
373
|
+
)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Build multiple sample events
|
|
377
|
+
#
|
|
378
|
+
# @param count [Integer] Number of events to create
|
|
379
|
+
# @param type [String] Event type
|
|
380
|
+
# @return [Array<Models::Event>] Array of event objects
|
|
381
|
+
def self.sample_events(count = 3, type: 'test.event')
|
|
382
|
+
Array.new(count) do |i|
|
|
383
|
+
build_jetstream_event(
|
|
384
|
+
event_type: type,
|
|
385
|
+
payload: { id: i + 1, sequence: i }
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Build a generic event with custom attributes
|
|
391
|
+
#
|
|
392
|
+
# @param event_type [String] Event type
|
|
393
|
+
# @param payload [Hash] Event payload
|
|
394
|
+
# @param attrs [Hash] Additional event attributes
|
|
395
|
+
# @return [Models::Event] Event object
|
|
396
|
+
def self.event(event_type:, payload: {}, **attrs)
|
|
397
|
+
build_jetstream_event(
|
|
398
|
+
event_type: event_type,
|
|
399
|
+
payload: payload,
|
|
400
|
+
**attrs
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Helper method to build events
|
|
405
|
+
# @private
|
|
406
|
+
def self.build_jetstream_event(event_type:, payload:, event_id: nil, trace_id: nil, occurred_at: nil, **metadata)
|
|
407
|
+
# Delegate to main module method to avoid duplication
|
|
408
|
+
TestHelpers.build_jetstream_event(
|
|
409
|
+
event_type: event_type,
|
|
410
|
+
payload: payload,
|
|
411
|
+
event_id: event_id,
|
|
412
|
+
trace_id: trace_id,
|
|
413
|
+
occurred_at: occurred_at,
|
|
414
|
+
**metadata
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Integration test helpers for end-to-end testing
|
|
420
|
+
module IntegrationHelpers
|
|
421
|
+
# Publish an event and wait for it to appear in mock storage
|
|
422
|
+
#
|
|
423
|
+
# @param event_attrs [Hash] Event attributes to publish
|
|
424
|
+
# @param timeout [Integer] Maximum seconds to wait
|
|
425
|
+
# @return [Models::PublishResult] Publish result
|
|
426
|
+
# @raise [Timeout::Error] If event doesn't appear within timeout
|
|
427
|
+
def publish_and_wait(event_attrs, timeout: 1)
|
|
428
|
+
result = JetstreamBridge.publish(**event_attrs)
|
|
429
|
+
|
|
430
|
+
deadline = Time.now + timeout
|
|
431
|
+
until Time.now > deadline
|
|
432
|
+
storage = JetstreamBridge::TestHelpers.mock_storage
|
|
433
|
+
break if storage.messages.any? { |m| m[:header]['nats-msg-id'] == result.event_id }
|
|
434
|
+
|
|
435
|
+
sleep 0.01
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
result
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Consume events from mock storage
|
|
442
|
+
#
|
|
443
|
+
# @param batch_size [Integer] Number of events to consume
|
|
444
|
+
# @yield [event] Block to handle each event
|
|
445
|
+
# @return [Array<Hash>] Consumed events
|
|
446
|
+
def consume_events(batch_size: 10, &handler)
|
|
447
|
+
storage = JetstreamBridge::TestHelpers.mock_storage
|
|
448
|
+
messages = storage.messages.first(batch_size)
|
|
449
|
+
|
|
450
|
+
messages.each do |msg|
|
|
451
|
+
event = JetstreamBridge::Models::Event.from_nats_message(
|
|
452
|
+
OpenStruct.new(
|
|
453
|
+
subject: msg[:subject],
|
|
454
|
+
data: msg[:data],
|
|
455
|
+
header: msg[:header],
|
|
456
|
+
metadata: OpenStruct.new(
|
|
457
|
+
sequence: OpenStruct.new(stream: msg[:sequence]),
|
|
458
|
+
num_delivered: msg[:delivery_count],
|
|
459
|
+
stream: 'test-stream',
|
|
460
|
+
consumer: 'test-consumer'
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
handler&.call(event)
|
|
466
|
+
JetstreamBridge::TestHelpers.record_consumed_event(event.to_h)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
JetstreamBridge::TestHelpers.consumed_events
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Wait for a specific number of messages in mock storage
|
|
473
|
+
#
|
|
474
|
+
# @param count [Integer] Expected message count
|
|
475
|
+
# @param timeout [Integer] Maximum seconds to wait
|
|
476
|
+
# @return [Boolean] true if count reached, false if timeout
|
|
477
|
+
def wait_for_messages(count, timeout: 2)
|
|
478
|
+
deadline = Time.now + timeout
|
|
479
|
+
storage = JetstreamBridge::TestHelpers.mock_storage
|
|
480
|
+
|
|
481
|
+
until Time.now > deadline
|
|
482
|
+
return true if storage.messages.size >= count
|
|
483
|
+
|
|
484
|
+
sleep 0.01
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
false
|
|
488
|
+
end
|
|
489
|
+
end
|
|
274
490
|
end
|
|
275
491
|
end
|
|
492
|
+
|
|
493
|
+
# Auto-configure when loaded (only if RSpec is available)
|
|
494
|
+
JetstreamBridge::TestHelpers.auto_configure! if defined?(RSpec)
|
|
@@ -7,6 +7,12 @@ require_relative '../core/logging'
|
|
|
7
7
|
module JetstreamBridge
|
|
8
8
|
# Checks for overlapping subjects.
|
|
9
9
|
class OverlapGuard
|
|
10
|
+
# Cache for stream metadata to reduce N+1 API calls
|
|
11
|
+
@cache_mutex = Mutex.new
|
|
12
|
+
@stream_cache = {}
|
|
13
|
+
@cache_expires_at = Time.at(0)
|
|
14
|
+
CACHE_TTL = 60 # seconds
|
|
15
|
+
|
|
10
16
|
class << self
|
|
11
17
|
# Raise if any desired subjects conflict with other streams.
|
|
12
18
|
def check!(jts, target_name, new_subjects)
|
|
@@ -46,9 +52,52 @@ module JetstreamBridge
|
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
def list_streams_with_subjects(jts)
|
|
55
|
+
# Use cached data if available and fresh
|
|
56
|
+
@cache_mutex.synchronize do
|
|
57
|
+
now = Time.now
|
|
58
|
+
if now < @cache_expires_at && @stream_cache.key?(:data)
|
|
59
|
+
Logging.debug(
|
|
60
|
+
'Using cached stream metadata',
|
|
61
|
+
tag: 'JetstreamBridge::OverlapGuard'
|
|
62
|
+
)
|
|
63
|
+
return @stream_cache[:data]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Fetch fresh data
|
|
67
|
+
Logging.debug(
|
|
68
|
+
'Fetching fresh stream metadata from NATS',
|
|
69
|
+
tag: 'JetstreamBridge::OverlapGuard'
|
|
70
|
+
)
|
|
71
|
+
result = fetch_streams_uncached(jts)
|
|
72
|
+
@stream_cache = { data: result }
|
|
73
|
+
@cache_expires_at = now + CACHE_TTL
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
Logging.warn(
|
|
78
|
+
"Failed to fetch stream metadata: #{e.class} #{e.message}",
|
|
79
|
+
tag: 'JetstreamBridge::OverlapGuard'
|
|
80
|
+
)
|
|
81
|
+
# Return cached data on error if available, otherwise empty array
|
|
82
|
+
@cache_mutex.synchronize { @stream_cache[:data] || [] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Clear the cache (useful for testing)
|
|
86
|
+
def clear_cache!
|
|
87
|
+
@cache_mutex.synchronize do
|
|
88
|
+
@stream_cache = {}
|
|
89
|
+
@cache_expires_at = Time.at(0)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Fetch stream metadata without caching (for internal use)
|
|
94
|
+
def fetch_streams_uncached(jts)
|
|
49
95
|
list_stream_names(jts).map do |name|
|
|
50
96
|
info = jts.stream_info(name)
|
|
51
|
-
|
|
97
|
+
# Handle both object-style and hash-style access for compatibility
|
|
98
|
+
config_data = info.config
|
|
99
|
+
subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
|
|
100
|
+
{ name: name, subjects: Array(subjects || []) }
|
|
52
101
|
end
|
|
53
102
|
end
|
|
54
103
|
|
|
@@ -127,28 +127,36 @@ module JetstreamBridge
|
|
|
127
127
|
private
|
|
128
128
|
|
|
129
129
|
def ensure_update(jts, name, info, desired_subjects)
|
|
130
|
-
|
|
130
|
+
# Handle both object-style and hash-style access for compatibility
|
|
131
|
+
config_data = info.config
|
|
132
|
+
subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
|
|
133
|
+
existing = StreamSupport.normalize_subjects(subjects || [])
|
|
131
134
|
to_add = StreamSupport.missing_subjects(existing, desired_subjects)
|
|
132
135
|
|
|
133
136
|
# Retention is immutable; if different, skip all updates to avoid 10052 error.
|
|
134
|
-
|
|
137
|
+
retention = config_data.respond_to?(:retention) ? config_data.retention : config_data[:retention]
|
|
138
|
+
have_ret = retention.to_s.downcase
|
|
135
139
|
if have_ret != RETENTION
|
|
136
140
|
StreamSupport.log_retention_mismatch(name, have: have_ret, want: RETENTION)
|
|
137
141
|
return
|
|
138
142
|
end
|
|
139
143
|
|
|
140
|
-
|
|
144
|
+
# Add subjects if needed and return (logging is handled in add_subjects)
|
|
145
|
+
if to_add.any?
|
|
146
|
+
add_subjects(jts, name, existing, to_add)
|
|
147
|
+
return
|
|
148
|
+
end
|
|
141
149
|
|
|
142
150
|
# Storage can be updated; do it without passing retention.
|
|
143
|
-
|
|
151
|
+
storage = config_data.respond_to?(:storage) ? config_data.storage : config_data[:storage]
|
|
152
|
+
have_storage = storage.to_s.downcase
|
|
144
153
|
if have_storage != STORAGE
|
|
145
154
|
apply_update(jts, name, existing, storage: STORAGE)
|
|
146
155
|
StreamSupport.log_config_updated(name, storage: STORAGE)
|
|
147
156
|
return
|
|
148
157
|
end
|
|
149
158
|
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
# If we reach here, nothing was updated
|
|
152
160
|
StreamSupport.log_already_covered(name)
|
|
153
161
|
end
|
|
154
162
|
|
data/lib/jetstream_bridge.rb
CHANGED
|
@@ -31,9 +31,10 @@ require_relative 'jetstream_bridge/models/outbox_event'
|
|
|
31
31
|
# - Built-in health checks and monitoring
|
|
32
32
|
# - Middleware support for cross-cutting concerns
|
|
33
33
|
# - Rails integration with generators and migrations
|
|
34
|
+
# - Graceful startup/shutdown lifecycle management
|
|
34
35
|
#
|
|
35
36
|
# @example Quick start
|
|
36
|
-
# # Configure
|
|
37
|
+
# # Configure (automatically starts connection)
|
|
37
38
|
# JetstreamBridge.configure do |config|
|
|
38
39
|
# config.nats_urls = "nats://localhost:4222"
|
|
39
40
|
# config.env = "development"
|
|
@@ -50,9 +51,13 @@ require_relative 'jetstream_bridge/models/outbox_event'
|
|
|
50
51
|
# )
|
|
51
52
|
#
|
|
52
53
|
# # Consume events
|
|
53
|
-
# JetstreamBridge.subscribe do |event|
|
|
54
|
+
# consumer = JetstreamBridge.subscribe do |event|
|
|
54
55
|
# puts "Received: #{event.type} - #{event.payload.to_h}"
|
|
55
|
-
# end
|
|
56
|
+
# end
|
|
57
|
+
# consumer.run!
|
|
58
|
+
#
|
|
59
|
+
# # Graceful shutdown
|
|
60
|
+
# at_exit { JetstreamBridge.shutdown! }
|
|
56
61
|
#
|
|
57
62
|
# @see Publisher For publishing events
|
|
58
63
|
# @see Consumer For consuming events
|
|
@@ -65,14 +70,52 @@ module JetstreamBridge
|
|
|
65
70
|
@config ||= Config.new
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
|
|
73
|
+
# Configure JetStream Bridge settings and establish connection
|
|
74
|
+
#
|
|
75
|
+
# This method sets configuration and immediately establishes a connection
|
|
76
|
+
# to NATS, providing fail-fast behavior during application startup.
|
|
77
|
+
# If NATS is unavailable, the application will fail to start.
|
|
78
|
+
#
|
|
79
|
+
# Set config.lazy_connect = true to defer connection until first use.
|
|
80
|
+
#
|
|
81
|
+
# @example Basic configuration
|
|
82
|
+
# JetstreamBridge.configure do |config|
|
|
83
|
+
# config.nats_urls = "nats://localhost:4222"
|
|
84
|
+
# config.app_name = "my_app"
|
|
85
|
+
# config.destination_app = "worker"
|
|
86
|
+
# end
|
|
87
|
+
#
|
|
88
|
+
# @example With hash overrides
|
|
89
|
+
# JetstreamBridge.configure(env: 'production', app_name: 'my_app')
|
|
90
|
+
#
|
|
91
|
+
# @example Lazy connection (defer until first use)
|
|
92
|
+
# JetstreamBridge.configure do |config|
|
|
93
|
+
# config.nats_urls = "nats://localhost:4222"
|
|
94
|
+
# config.lazy_connect = true
|
|
95
|
+
# end
|
|
96
|
+
#
|
|
97
|
+
# @param overrides [Hash] Configuration key-value pairs to set
|
|
98
|
+
# @yield [Config] Configuration object for block-based configuration
|
|
99
|
+
# @return [Config] The configured instance
|
|
100
|
+
# @raise [ConnectionError] If connection to NATS fails (unless lazy_connect is true)
|
|
101
|
+
def configure(overrides = {}, **extra_overrides)
|
|
102
|
+
# Merge extra keyword arguments into overrides hash
|
|
103
|
+
all_overrides = overrides.nil? ? extra_overrides : overrides.merge(extra_overrides)
|
|
104
|
+
|
|
69
105
|
cfg = config
|
|
70
|
-
|
|
106
|
+
all_overrides.each { |k, v| assign!(cfg, k, v) } unless all_overrides.empty?
|
|
71
107
|
yield(cfg) if block_given?
|
|
108
|
+
|
|
109
|
+
# Establish connection immediately for fail-fast behavior (unless lazy_connect is true)
|
|
110
|
+
startup! unless cfg.lazy_connect
|
|
111
|
+
|
|
72
112
|
cfg
|
|
73
113
|
end
|
|
74
114
|
|
|
75
|
-
# Configure with a preset
|
|
115
|
+
# Configure with a preset and establish connection
|
|
116
|
+
#
|
|
117
|
+
# This method applies a configuration preset and immediately establishes
|
|
118
|
+
# a connection to NATS, providing fail-fast behavior.
|
|
76
119
|
#
|
|
77
120
|
# @example
|
|
78
121
|
# JetstreamBridge.configure_for(:production) do |config|
|
|
@@ -84,6 +127,7 @@ module JetstreamBridge
|
|
|
84
127
|
# @param preset [Symbol] Preset name (:development, :test, :production, etc.)
|
|
85
128
|
# @yield [Config] Configuration object
|
|
86
129
|
# @return [Config] Configured instance
|
|
130
|
+
# @raise [ConnectionError] If connection to NATS fails
|
|
87
131
|
def configure_for(preset)
|
|
88
132
|
configure do |cfg|
|
|
89
133
|
cfg.apply_preset(preset)
|
|
@@ -93,6 +137,41 @@ module JetstreamBridge
|
|
|
93
137
|
|
|
94
138
|
def reset!
|
|
95
139
|
@config = nil
|
|
140
|
+
@connection_initialized = false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Initialize the JetStream Bridge connection and topology
|
|
144
|
+
#
|
|
145
|
+
# This method is called automatically by `configure`, but can be called
|
|
146
|
+
# explicitly if needed. It's idempotent and safe to call multiple times.
|
|
147
|
+
#
|
|
148
|
+
# @return [void]
|
|
149
|
+
def startup!
|
|
150
|
+
return if @connection_initialized
|
|
151
|
+
|
|
152
|
+
Connection.connect!
|
|
153
|
+
@connection_initialized = true
|
|
154
|
+
Logging.info('JetStream Bridge started successfully', tag: 'JetstreamBridge')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Gracefully shutdown the JetStream Bridge connection
|
|
158
|
+
#
|
|
159
|
+
# Closes the NATS connection and cleans up resources. Should be called
|
|
160
|
+
# during application shutdown (e.g., in at_exit or signal handlers).
|
|
161
|
+
#
|
|
162
|
+
# @return [void]
|
|
163
|
+
def shutdown!
|
|
164
|
+
return unless @connection_initialized
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
nc = Connection.nc
|
|
168
|
+
nc&.close if nc&.connected?
|
|
169
|
+
Logging.info('JetStream Bridge shut down gracefully', tag: 'JetstreamBridge')
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
Logging.error("Error during shutdown: #{e.message}", tag: 'JetstreamBridge')
|
|
172
|
+
ensure
|
|
173
|
+
@connection_initialized = false
|
|
174
|
+
end
|
|
96
175
|
end
|
|
97
176
|
|
|
98
177
|
def use_outbox?
|
|
@@ -115,21 +194,56 @@ module JetstreamBridge
|
|
|
115
194
|
Connection.jetstream
|
|
116
195
|
end
|
|
117
196
|
|
|
118
|
-
#
|
|
197
|
+
# Active health check for monitoring and readiness probes
|
|
198
|
+
#
|
|
199
|
+
# Performs actual operations to verify system health:
|
|
200
|
+
# - Checks NATS connection (active: calls account_info API)
|
|
201
|
+
# - Verifies stream exists and is accessible (active: queries stream info)
|
|
202
|
+
# - Tests NATS round-trip communication (active: RTT measurement)
|
|
119
203
|
#
|
|
204
|
+
# Rate Limiting: To prevent abuse, uncached health checks are limited to once every 5 seconds.
|
|
205
|
+
# Cached results (within 30s TTL) bypass this limit via Connection.instance.connected?.
|
|
206
|
+
#
|
|
207
|
+
# @param skip_cache [Boolean] Force fresh health check, bypass connection cache (rate limited)
|
|
120
208
|
# @return [Hash] Health status including NATS connection, stream, and version
|
|
121
|
-
|
|
209
|
+
# @raise [HealthCheckFailedError] If skip_cache requested too frequently
|
|
210
|
+
def health_check(skip_cache: false)
|
|
211
|
+
# Rate limit uncached requests to prevent abuse (max 1 per 5 seconds)
|
|
212
|
+
enforce_health_check_rate_limit! if skip_cache
|
|
213
|
+
|
|
214
|
+
start_time = Time.now
|
|
122
215
|
conn_instance = Connection.instance
|
|
123
|
-
|
|
216
|
+
|
|
217
|
+
# Active check: calls @jts.account_info internally
|
|
218
|
+
# Pass skip_cache to force fresh check if requested
|
|
219
|
+
connected = conn_instance.connected?(skip_cache: skip_cache)
|
|
124
220
|
connected_at = conn_instance.connected_at
|
|
221
|
+
connection_state = conn_instance.state
|
|
222
|
+
last_error = conn_instance.last_reconnect_error
|
|
223
|
+
last_error_at = conn_instance.last_reconnect_error_at
|
|
224
|
+
|
|
225
|
+
# Active check: queries actual stream from NATS server
|
|
226
|
+
stream_info = connected ? fetch_stream_info : { exists: false, name: config.stream_name }
|
|
227
|
+
|
|
228
|
+
# Active check: measure NATS round-trip time
|
|
229
|
+
rtt_ms = measure_nats_rtt if connected
|
|
125
230
|
|
|
126
|
-
|
|
231
|
+
health_check_duration_ms = ((Time.now - start_time) * 1000).round(2)
|
|
127
232
|
|
|
128
233
|
{
|
|
129
234
|
healthy: connected && stream_info&.fetch(:exists, false),
|
|
130
|
-
|
|
131
|
-
|
|
235
|
+
connection: {
|
|
236
|
+
state: connection_state,
|
|
237
|
+
connected: connected,
|
|
238
|
+
connected_at: connected_at&.iso8601,
|
|
239
|
+
last_error: last_error&.message,
|
|
240
|
+
last_error_at: last_error_at&.iso8601
|
|
241
|
+
},
|
|
132
242
|
stream: stream_info,
|
|
243
|
+
performance: {
|
|
244
|
+
nats_rtt_ms: rtt_ms,
|
|
245
|
+
health_check_duration_ms: health_check_duration_ms
|
|
246
|
+
},
|
|
133
247
|
config: {
|
|
134
248
|
env: config.env,
|
|
135
249
|
app_name: config.app_name,
|
|
@@ -143,6 +257,10 @@ module JetstreamBridge
|
|
|
143
257
|
rescue StandardError => e
|
|
144
258
|
{
|
|
145
259
|
healthy: false,
|
|
260
|
+
connection: {
|
|
261
|
+
state: :failed,
|
|
262
|
+
connected: false
|
|
263
|
+
},
|
|
146
264
|
error: "#{e.class}: #{e.message}"
|
|
147
265
|
}
|
|
148
266
|
end
|
|
@@ -278,14 +396,38 @@ module JetstreamBridge
|
|
|
278
396
|
|
|
279
397
|
private
|
|
280
398
|
|
|
399
|
+
# Enforce rate limit on uncached health checks to prevent abuse
|
|
400
|
+
# Max 1 uncached request per 5 seconds per process
|
|
401
|
+
def enforce_health_check_rate_limit!
|
|
402
|
+
@health_check_mutex ||= Mutex.new
|
|
403
|
+
@health_check_mutex.synchronize do
|
|
404
|
+
now = Time.now
|
|
405
|
+
if @last_uncached_health_check
|
|
406
|
+
time_since = now - @last_uncached_health_check
|
|
407
|
+
if time_since < 5
|
|
408
|
+
raise HealthCheckFailedError,
|
|
409
|
+
"Health check rate limit exceeded. Please wait #{(5 - time_since).ceil} second(s)"
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
@last_uncached_health_check = now
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
281
416
|
def fetch_stream_info
|
|
282
417
|
jts = Connection.jetstream
|
|
283
418
|
info = jts.stream_info(config.stream_name)
|
|
419
|
+
|
|
420
|
+
# Handle both object-style and hash-style access for compatibility
|
|
421
|
+
config_data = info.config
|
|
422
|
+
state_data = info.state
|
|
423
|
+
subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
|
|
424
|
+
messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
|
|
425
|
+
|
|
284
426
|
{
|
|
285
427
|
exists: true,
|
|
286
428
|
name: config.stream_name,
|
|
287
|
-
subjects:
|
|
288
|
-
messages:
|
|
429
|
+
subjects: subjects,
|
|
430
|
+
messages: messages
|
|
289
431
|
}
|
|
290
432
|
rescue StandardError => e
|
|
291
433
|
{
|
|
@@ -295,6 +437,20 @@ module JetstreamBridge
|
|
|
295
437
|
}
|
|
296
438
|
end
|
|
297
439
|
|
|
440
|
+
def measure_nats_rtt
|
|
441
|
+
# Measure round-trip time using NATS RTT method
|
|
442
|
+
nc = Connection.nc
|
|
443
|
+
start = Time.now
|
|
444
|
+
nc.rtt
|
|
445
|
+
((Time.now - start) * 1000).round(2)
|
|
446
|
+
rescue StandardError => e
|
|
447
|
+
Logging.warn(
|
|
448
|
+
"Failed to measure NATS RTT: #{e.class} #{e.message}",
|
|
449
|
+
tag: 'JetstreamBridge'
|
|
450
|
+
)
|
|
451
|
+
nil
|
|
452
|
+
end
|
|
453
|
+
|
|
298
454
|
def assign!(cfg, key, val)
|
|
299
455
|
setter = :"#{key}="
|
|
300
456
|
raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jetstream_bridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.0
|
|
4
|
+
version: 4.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Attara
|
|
@@ -68,16 +68,22 @@ dependencies:
|
|
|
68
68
|
name: nats-pure
|
|
69
69
|
requirement: !ruby/object:Gem::Requirement
|
|
70
70
|
requirements:
|
|
71
|
-
- - "
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: 2.4.0
|
|
74
|
+
- - "<"
|
|
72
75
|
- !ruby/object:Gem::Version
|
|
73
|
-
version: '
|
|
76
|
+
version: '3.0'
|
|
74
77
|
type: :runtime
|
|
75
78
|
prerelease: false
|
|
76
79
|
version_requirements: !ruby/object:Gem::Requirement
|
|
77
80
|
requirements:
|
|
78
|
-
- - "
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: 2.4.0
|
|
84
|
+
- - "<"
|
|
79
85
|
- !ruby/object:Gem::Version
|
|
80
|
-
version: '
|
|
86
|
+
version: '3.0'
|
|
81
87
|
- !ruby/object:Gem::Dependency
|
|
82
88
|
name: oj
|
|
83
89
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -152,6 +158,7 @@ files:
|
|
|
152
158
|
- lib/jetstream_bridge/railtie.rb
|
|
153
159
|
- lib/jetstream_bridge/tasks/install.rake
|
|
154
160
|
- lib/jetstream_bridge/test_helpers.rb
|
|
161
|
+
- lib/jetstream_bridge/test_helpers/mock_nats.rb
|
|
155
162
|
- lib/jetstream_bridge/topology/overlap_guard.rb
|
|
156
163
|
- lib/jetstream_bridge/topology/stream.rb
|
|
157
164
|
- lib/jetstream_bridge/topology/subject_matcher.rb
|