jetstream_bridge 4.0.4 → 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 +56 -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/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 +46 -0
- data/lib/jetstream_bridge/topology/stream.rb +6 -3
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +128 -11
- 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,6 +52,46 @@ 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
|
|
@@ -141,7 +141,11 @@ module JetstreamBridge
|
|
|
141
141
|
return
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
-
|
|
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
|
|
145
149
|
|
|
146
150
|
# Storage can be updated; do it without passing retention.
|
|
147
151
|
storage = config_data.respond_to?(:storage) ? config_data.storage : config_data[:storage]
|
|
@@ -152,8 +156,7 @@ module JetstreamBridge
|
|
|
152
156
|
return
|
|
153
157
|
end
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
# If we reach here, nothing was updated
|
|
157
160
|
StreamSupport.log_already_covered(name)
|
|
158
161
|
end
|
|
159
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?
|
|
@@ -122,17 +201,29 @@ module JetstreamBridge
|
|
|
122
201
|
# - Verifies stream exists and is accessible (active: queries stream info)
|
|
123
202
|
# - Tests NATS round-trip communication (active: RTT measurement)
|
|
124
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)
|
|
125
208
|
# @return [Hash] Health status including NATS connection, stream, and version
|
|
126
|
-
|
|
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
|
+
|
|
127
214
|
start_time = Time.now
|
|
128
215
|
conn_instance = Connection.instance
|
|
129
216
|
|
|
130
217
|
# Active check: calls @jts.account_info internally
|
|
131
|
-
|
|
218
|
+
# Pass skip_cache to force fresh check if requested
|
|
219
|
+
connected = conn_instance.connected?(skip_cache: skip_cache)
|
|
132
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
|
|
133
224
|
|
|
134
225
|
# Active check: queries actual stream from NATS server
|
|
135
|
-
stream_info = fetch_stream_info
|
|
226
|
+
stream_info = connected ? fetch_stream_info : { exists: false, name: config.stream_name }
|
|
136
227
|
|
|
137
228
|
# Active check: measure NATS round-trip time
|
|
138
229
|
rtt_ms = measure_nats_rtt if connected
|
|
@@ -141,8 +232,13 @@ module JetstreamBridge
|
|
|
141
232
|
|
|
142
233
|
{
|
|
143
234
|
healthy: connected && stream_info&.fetch(:exists, false),
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
},
|
|
146
242
|
stream: stream_info,
|
|
147
243
|
performance: {
|
|
148
244
|
nats_rtt_ms: rtt_ms,
|
|
@@ -161,6 +257,10 @@ module JetstreamBridge
|
|
|
161
257
|
rescue StandardError => e
|
|
162
258
|
{
|
|
163
259
|
healthy: false,
|
|
260
|
+
connection: {
|
|
261
|
+
state: :failed,
|
|
262
|
+
connected: false
|
|
263
|
+
},
|
|
164
264
|
error: "#{e.class}: #{e.message}"
|
|
165
265
|
}
|
|
166
266
|
end
|
|
@@ -296,6 +396,23 @@ module JetstreamBridge
|
|
|
296
396
|
|
|
297
397
|
private
|
|
298
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
|
+
|
|
299
416
|
def fetch_stream_info
|
|
300
417
|
jts = Connection.jetstream
|
|
301
418
|
info = jts.stream_info(config.stream_name)
|
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
|