jetstream_bridge 4.0.4 → 4.2.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 +106 -0
- data/README.md +22 -1402
- data/docs/GETTING_STARTED.md +92 -0
- data/docs/PRODUCTION.md +503 -0
- data/docs/TESTING.md +414 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +101 -5
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +17 -3
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +19 -7
- data/lib/jetstream_bridge/consumer/message_processor.rb +88 -52
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +24 -15
- data/lib/jetstream_bridge/core/bridge_helpers.rb +85 -0
- data/lib/jetstream_bridge/core/config.rb +27 -4
- data/lib/jetstream_bridge/core/connection.rb +162 -13
- data/lib/jetstream_bridge/core.rb +8 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +13 -7
- data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
- data/lib/jetstream_bridge/publisher/publisher.rb +10 -5
- data/lib/jetstream_bridge/rails/integration.rb +153 -0
- data/lib/jetstream_bridge/rails/railtie.rb +53 -0
- data/lib/jetstream_bridge/rails.rb +5 -0
- data/lib/jetstream_bridge/tasks/install.rake +1 -1
- data/lib/jetstream_bridge/test_helpers/fixtures.rb +41 -0
- data/lib/jetstream_bridge/test_helpers/integration_helpers.rb +77 -0
- data/lib/jetstream_bridge/test_helpers/matchers.rb +98 -0
- data/lib/jetstream_bridge/test_helpers/mock_nats.rb +524 -0
- data/lib/jetstream_bridge/test_helpers.rb +85 -121
- data/lib/jetstream_bridge/topology/overlap_guard.rb +46 -0
- data/lib/jetstream_bridge/topology/stream.rb +7 -4
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +138 -63
- metadata +32 -12
- data/lib/jetstream_bridge/railtie.rb +0 -49
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require_relative 'test_helpers/mock_nats'
|
|
5
|
+
require_relative 'test_helpers/matchers'
|
|
6
|
+
require_relative 'test_helpers/fixtures'
|
|
7
|
+
require_relative 'test_helpers/integration_helpers'
|
|
4
8
|
|
|
5
9
|
module JetstreamBridge
|
|
6
10
|
# Test helpers for easier testing of JetStream Bridge integrations
|
|
@@ -34,13 +38,50 @@ module JetstreamBridge
|
|
|
34
38
|
#
|
|
35
39
|
module TestHelpers
|
|
36
40
|
class << self
|
|
37
|
-
#
|
|
41
|
+
# Auto-configure test helpers when RSpec is detected
|
|
38
42
|
#
|
|
43
|
+
# This method is called automatically when test_helpers.rb is required.
|
|
44
|
+
# It sets up RSpec configuration to enable test mode for tests tagged with :jetstream.
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
47
|
+
def auto_configure!
|
|
48
|
+
return unless defined?(RSpec)
|
|
49
|
+
return if @configured
|
|
50
|
+
|
|
51
|
+
RSpec.configure do |config|
|
|
52
|
+
config.include JetstreamBridge::TestHelpers
|
|
53
|
+
config.include JetstreamBridge::TestHelpers::Matchers
|
|
54
|
+
|
|
55
|
+
config.before(:each, :jetstream) do
|
|
56
|
+
JetstreamBridge::TestHelpers.enable_test_mode!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
config.after(:each, :jetstream) do
|
|
60
|
+
JetstreamBridge::TestHelpers.reset_test_mode!
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@configured = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if auto-configuration has been applied
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def configured?
|
|
71
|
+
@configured ||= false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Enable test mode with in-memory event capture and mock NATS connection
|
|
75
|
+
#
|
|
76
|
+
# @param use_mock_nats [Boolean] Whether to use mock NATS connection (default: true)
|
|
39
77
|
# @return [void]
|
|
40
|
-
def enable_test_mode!
|
|
78
|
+
def enable_test_mode!(use_mock_nats: true)
|
|
41
79
|
@test_mode = true
|
|
42
80
|
@published_events = []
|
|
43
81
|
@consumed_events = []
|
|
82
|
+
@mock_nats_enabled = use_mock_nats
|
|
83
|
+
|
|
84
|
+
setup_mock_nats if use_mock_nats
|
|
44
85
|
end
|
|
45
86
|
|
|
46
87
|
# Reset test mode and clear captured events
|
|
@@ -50,6 +91,44 @@ module JetstreamBridge
|
|
|
50
91
|
@test_mode = false
|
|
51
92
|
@published_events = []
|
|
52
93
|
@consumed_events = []
|
|
94
|
+
|
|
95
|
+
teardown_mock_nats if @mock_nats_enabled
|
|
96
|
+
@mock_nats_enabled = false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Setup mock NATS connection
|
|
100
|
+
#
|
|
101
|
+
# @return [void]
|
|
102
|
+
def setup_mock_nats
|
|
103
|
+
MockNats.reset!
|
|
104
|
+
@mock_connection = MockNats.create_mock_connection
|
|
105
|
+
@mock_connection.connect
|
|
106
|
+
|
|
107
|
+
# Store the mock for Connection to use
|
|
108
|
+
JetstreamBridge.instance_variable_set(:@mock_nats_client, @mock_connection)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Teardown mock NATS connection
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
def teardown_mock_nats
|
|
115
|
+
MockNats.reset!
|
|
116
|
+
@mock_connection = nil
|
|
117
|
+
return unless JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
|
|
118
|
+
|
|
119
|
+
JetstreamBridge.remove_instance_variable(:@mock_nats_client)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get the current mock connection
|
|
123
|
+
#
|
|
124
|
+
# @return [MockNats::MockConnection, nil]
|
|
125
|
+
attr_reader :mock_connection
|
|
126
|
+
|
|
127
|
+
# Get the mock storage for direct access in tests
|
|
128
|
+
#
|
|
129
|
+
# @return [MockNats::InMemoryStorage]
|
|
130
|
+
def mock_storage
|
|
131
|
+
MockNats.storage
|
|
53
132
|
end
|
|
54
133
|
|
|
55
134
|
# Check if test mode is enabled
|
|
@@ -134,6 +213,7 @@ module JetstreamBridge
|
|
|
134
213
|
}
|
|
135
214
|
)
|
|
136
215
|
end
|
|
216
|
+
module_function :build_jetstream_event
|
|
137
217
|
|
|
138
218
|
# Simulate triggering an event to a consumer
|
|
139
219
|
#
|
|
@@ -152,124 +232,8 @@ module JetstreamBridge
|
|
|
152
232
|
TestHelpers.record_consumed_event(event.to_h) if TestHelpers.test_mode?
|
|
153
233
|
handler.call(event)
|
|
154
234
|
end
|
|
155
|
-
|
|
156
|
-
# RSpec matchers module
|
|
157
|
-
#
|
|
158
|
-
# @example Include in RSpec
|
|
159
|
-
# RSpec.configure do |config|
|
|
160
|
-
# config.include JetstreamBridge::TestHelpers
|
|
161
|
-
# config.include JetstreamBridge::TestHelpers::Matchers
|
|
162
|
-
# end
|
|
163
|
-
#
|
|
164
|
-
module Matchers
|
|
165
|
-
# Matcher for checking if an event was published
|
|
166
|
-
#
|
|
167
|
-
# @param event_type [String] Event type to match
|
|
168
|
-
# @param payload [Hash] Optional payload attributes to match
|
|
169
|
-
# @return [HavePublished] Matcher instance
|
|
170
|
-
#
|
|
171
|
-
# @example
|
|
172
|
-
# expect(JetstreamBridge).to have_published(
|
|
173
|
-
# event_type: "user.created",
|
|
174
|
-
# payload: { id: 1 }
|
|
175
|
-
# )
|
|
176
|
-
#
|
|
177
|
-
def have_published(event_type:, payload: {})
|
|
178
|
-
HavePublished.new(event_type, payload)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# Matcher implementation for have_published
|
|
182
|
-
class HavePublished
|
|
183
|
-
def initialize(event_type, payload_attributes)
|
|
184
|
-
@event_type = event_type
|
|
185
|
-
@payload_attributes = payload_attributes
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def matches?(_actual)
|
|
189
|
-
TestHelpers.published_events.any? do |event|
|
|
190
|
-
matches_event_type?(event) && matches_payload?(event)
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def failure_message
|
|
195
|
-
"expected to have published event_type: #{@event_type.inspect} " \
|
|
196
|
-
"with payload: #{@payload_attributes.inspect}\n" \
|
|
197
|
-
"but found events: #{TestHelpers.published_events.map { |e| e['event_type'] }.inspect}"
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def failure_message_when_negated
|
|
201
|
-
"expected not to have published event_type: #{@event_type.inspect} " \
|
|
202
|
-
"with payload: #{@payload_attributes.inspect}"
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
private
|
|
206
|
-
|
|
207
|
-
def matches_event_type?(event)
|
|
208
|
-
event['event_type'] == @event_type || event[:event_type] == @event_type
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def matches_payload?(event)
|
|
212
|
-
payload = event['payload'] || event[:payload] || {}
|
|
213
|
-
@payload_attributes.all? do |key, value|
|
|
214
|
-
payload_value = payload[key.to_s] || payload[key.to_sym]
|
|
215
|
-
if value.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher)
|
|
216
|
-
value.matches?(payload)
|
|
217
|
-
else
|
|
218
|
-
payload_value == value
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Matcher for checking publish result
|
|
225
|
-
#
|
|
226
|
-
# @example
|
|
227
|
-
# result = JetstreamBridge.publish(...)
|
|
228
|
-
# expect(result).to be_publish_success
|
|
229
|
-
#
|
|
230
|
-
def be_publish_success
|
|
231
|
-
BePublishSuccess.new
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# Matcher implementation for be_publish_success
|
|
235
|
-
class BePublishSuccess
|
|
236
|
-
def matches?(actual)
|
|
237
|
-
actual.respond_to?(:success?) && actual.success?
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def failure_message
|
|
241
|
-
'expected PublishResult to be successful but it failed'
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def failure_message_when_negated
|
|
245
|
-
'expected PublishResult to not be successful but it was'
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
# Matcher for checking publish failure
|
|
250
|
-
#
|
|
251
|
-
# @example
|
|
252
|
-
# result = JetstreamBridge.publish(...)
|
|
253
|
-
# expect(result).to be_publish_failure
|
|
254
|
-
#
|
|
255
|
-
def be_publish_failure
|
|
256
|
-
BePublishFailure.new
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
# Matcher implementation for be_publish_failure
|
|
260
|
-
class BePublishFailure
|
|
261
|
-
def matches?(actual)
|
|
262
|
-
actual.respond_to?(:failure?) && actual.failure?
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def failure_message
|
|
266
|
-
'expected PublishResult to be a failure but it succeeded'
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
def failure_message_when_negated
|
|
270
|
-
'expected PublishResult to not be a failure but it was'
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
235
|
end
|
|
275
236
|
end
|
|
237
|
+
|
|
238
|
+
# Auto-configure when loaded (only if RSpec is available)
|
|
239
|
+
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
|
|
@@ -79,7 +79,7 @@ module JetstreamBridge
|
|
|
79
79
|
def log_retention_mismatch(name, have:, want:)
|
|
80
80
|
Logging.warn(
|
|
81
81
|
"Stream #{name} retention mismatch (have=#{have.inspect}, want=#{want.inspect}). " \
|
|
82
|
-
|
|
82
|
+
'Retention is immutable; skipping retention change.',
|
|
83
83
|
tag: 'JetstreamBridge::Stream'
|
|
84
84
|
)
|
|
85
85
|
end
|
|
@@ -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
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'jetstream_bridge/version'
|
|
4
|
-
require_relative 'jetstream_bridge/core
|
|
5
|
-
require_relative 'jetstream_bridge/core/duration'
|
|
6
|
-
require_relative 'jetstream_bridge/core/logging'
|
|
7
|
-
require_relative 'jetstream_bridge/core/connection'
|
|
4
|
+
require_relative 'jetstream_bridge/core'
|
|
8
5
|
require_relative 'jetstream_bridge/publisher/publisher'
|
|
9
6
|
require_relative 'jetstream_bridge/publisher/batch_publisher'
|
|
10
7
|
require_relative 'jetstream_bridge/consumer/consumer'
|
|
@@ -12,8 +9,8 @@ require_relative 'jetstream_bridge/consumer/middleware'
|
|
|
12
9
|
require_relative 'jetstream_bridge/models/publish_result'
|
|
13
10
|
require_relative 'jetstream_bridge/models/event'
|
|
14
11
|
|
|
15
|
-
#
|
|
16
|
-
require_relative 'jetstream_bridge/
|
|
12
|
+
# Rails-specific entry point (lifecycle helpers + Railtie)
|
|
13
|
+
require_relative 'jetstream_bridge/rails' if defined?(Rails::Railtie)
|
|
17
14
|
|
|
18
15
|
# Load gem-provided models from lib/
|
|
19
16
|
require_relative 'jetstream_bridge/models/inbox_event'
|
|
@@ -31,6 +28,7 @@ require_relative 'jetstream_bridge/models/outbox_event'
|
|
|
31
28
|
# - Built-in health checks and monitoring
|
|
32
29
|
# - Middleware support for cross-cutting concerns
|
|
33
30
|
# - Rails integration with generators and migrations
|
|
31
|
+
# - Graceful startup/shutdown lifecycle management
|
|
34
32
|
#
|
|
35
33
|
# @example Quick start
|
|
36
34
|
# # Configure
|
|
@@ -43,6 +41,9 @@ require_relative 'jetstream_bridge/models/outbox_event'
|
|
|
43
41
|
# config.use_inbox = true
|
|
44
42
|
# end
|
|
45
43
|
#
|
|
44
|
+
# # Explicitly start connection (or use Rails railtie for automatic startup)
|
|
45
|
+
# JetstreamBridge.startup!
|
|
46
|
+
#
|
|
46
47
|
# # Publish events
|
|
47
48
|
# JetstreamBridge.publish(
|
|
48
49
|
# event_type: "user.created",
|
|
@@ -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
|
|
@@ -61,25 +66,55 @@ require_relative 'jetstream_bridge/models/outbox_event'
|
|
|
61
66
|
#
|
|
62
67
|
module JetstreamBridge
|
|
63
68
|
class << self
|
|
69
|
+
include Core::BridgeHelpers
|
|
70
|
+
|
|
64
71
|
def config
|
|
65
72
|
@config ||= Config.new
|
|
66
73
|
end
|
|
67
74
|
|
|
68
|
-
|
|
75
|
+
# Configure JetStream Bridge settings
|
|
76
|
+
#
|
|
77
|
+
# This method sets configuration WITHOUT automatically establishing a connection.
|
|
78
|
+
# Connection must be established explicitly via startup! or will be established
|
|
79
|
+
# automatically on first use (publish/subscribe) or via Rails railtie initialization.
|
|
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
|
+
# JetstreamBridge.startup! # Explicitly start connection
|
|
88
|
+
#
|
|
89
|
+
# @example With hash overrides
|
|
90
|
+
# JetstreamBridge.configure(env: 'production', app_name: 'my_app')
|
|
91
|
+
#
|
|
92
|
+
# @param overrides [Hash] Configuration key-value pairs to set
|
|
93
|
+
# @yield [Config] Configuration object for block-based configuration
|
|
94
|
+
# @return [Config] The configured instance
|
|
95
|
+
def configure(overrides = {}, **extra_overrides)
|
|
96
|
+
# Merge extra keyword arguments into overrides hash
|
|
97
|
+
all_overrides = overrides.nil? ? extra_overrides : overrides.merge(extra_overrides)
|
|
98
|
+
|
|
69
99
|
cfg = config
|
|
70
|
-
|
|
100
|
+
all_overrides.each { |k, v| assign_config_option!(cfg, k, v) } unless all_overrides.empty?
|
|
71
101
|
yield(cfg) if block_given?
|
|
102
|
+
|
|
72
103
|
cfg
|
|
73
104
|
end
|
|
74
105
|
|
|
75
106
|
# Configure with a preset
|
|
76
107
|
#
|
|
108
|
+
# This method applies a configuration preset. Connection must be
|
|
109
|
+
# established separately via startup! or via Rails railtie.
|
|
110
|
+
#
|
|
77
111
|
# @example
|
|
78
112
|
# JetstreamBridge.configure_for(:production) do |config|
|
|
79
113
|
# config.nats_urls = ENV["NATS_URLS"]
|
|
80
114
|
# config.app_name = "my_app"
|
|
81
115
|
# config.destination_app = "worker"
|
|
82
116
|
# end
|
|
117
|
+
# JetstreamBridge.startup! # Explicitly start connection
|
|
83
118
|
#
|
|
84
119
|
# @param preset [Symbol] Preset name (:development, :test, :production, etc.)
|
|
85
120
|
# @yield [Config] Configuration object
|
|
@@ -93,6 +128,61 @@ module JetstreamBridge
|
|
|
93
128
|
|
|
94
129
|
def reset!
|
|
95
130
|
@config = nil
|
|
131
|
+
@connection_initialized = false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Initialize the JetStream Bridge connection and topology
|
|
135
|
+
#
|
|
136
|
+
# This method is called automatically by `configure`, but can be called
|
|
137
|
+
# explicitly if needed. It's idempotent and safe to call multiple times.
|
|
138
|
+
#
|
|
139
|
+
# @return [void]
|
|
140
|
+
def startup!
|
|
141
|
+
return if @connection_initialized
|
|
142
|
+
|
|
143
|
+
Connection.connect!
|
|
144
|
+
@connection_initialized = true
|
|
145
|
+
Logging.info('JetStream Bridge started successfully', tag: 'JetstreamBridge')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Reconnect to NATS
|
|
149
|
+
#
|
|
150
|
+
# Closes existing connection and establishes a new one. Useful for:
|
|
151
|
+
# - Forking web servers (Puma, Unicorn) after worker boot
|
|
152
|
+
# - Recovering from connection issues
|
|
153
|
+
# - Configuration changes that require reconnection
|
|
154
|
+
#
|
|
155
|
+
# @example In Puma configuration (config/puma.rb)
|
|
156
|
+
# on_worker_boot do
|
|
157
|
+
# JetstreamBridge.reconnect! if defined?(JetstreamBridge)
|
|
158
|
+
# end
|
|
159
|
+
#
|
|
160
|
+
# @return [void]
|
|
161
|
+
# @raise [ConnectionError] If unable to reconnect to NATS
|
|
162
|
+
def reconnect!
|
|
163
|
+
Logging.info('Reconnecting to NATS...', tag: 'JetstreamBridge')
|
|
164
|
+
shutdown! if @connection_initialized
|
|
165
|
+
startup!
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Gracefully shutdown the JetStream Bridge connection
|
|
169
|
+
#
|
|
170
|
+
# Closes the NATS connection and cleans up resources. Should be called
|
|
171
|
+
# during application shutdown (e.g., in at_exit or signal handlers).
|
|
172
|
+
#
|
|
173
|
+
# @return [void]
|
|
174
|
+
def shutdown!
|
|
175
|
+
return unless @connection_initialized
|
|
176
|
+
|
|
177
|
+
begin
|
|
178
|
+
nc = Connection.nc
|
|
179
|
+
nc&.close if nc&.connected?
|
|
180
|
+
Logging.info('JetStream Bridge shut down gracefully', tag: 'JetstreamBridge')
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
Logging.error("Error during shutdown: #{e.message}", tag: 'JetstreamBridge')
|
|
183
|
+
ensure
|
|
184
|
+
@connection_initialized = false
|
|
185
|
+
end
|
|
96
186
|
end
|
|
97
187
|
|
|
98
188
|
def use_outbox?
|
|
@@ -110,11 +200,16 @@ module JetstreamBridge
|
|
|
110
200
|
# Establishes a connection and ensures stream topology.
|
|
111
201
|
#
|
|
112
202
|
# @return [Object] JetStream context
|
|
113
|
-
def
|
|
203
|
+
def connect_and_ensure_stream!
|
|
114
204
|
Connection.connect!
|
|
115
205
|
Connection.jetstream
|
|
116
206
|
end
|
|
117
207
|
|
|
208
|
+
# Backwards-compatible alias for the previous method name
|
|
209
|
+
def ensure_topology!
|
|
210
|
+
connect_and_ensure_stream!
|
|
211
|
+
end
|
|
212
|
+
|
|
118
213
|
# Active health check for monitoring and readiness probes
|
|
119
214
|
#
|
|
120
215
|
# Performs actual operations to verify system health:
|
|
@@ -122,17 +217,29 @@ module JetstreamBridge
|
|
|
122
217
|
# - Verifies stream exists and is accessible (active: queries stream info)
|
|
123
218
|
# - Tests NATS round-trip communication (active: RTT measurement)
|
|
124
219
|
#
|
|
220
|
+
# Rate Limiting: To prevent abuse, uncached health checks are limited to once every 5 seconds.
|
|
221
|
+
# Cached results (within 30s TTL) bypass this limit via Connection.instance.connected?.
|
|
222
|
+
#
|
|
223
|
+
# @param skip_cache [Boolean] Force fresh health check, bypass connection cache (rate limited)
|
|
125
224
|
# @return [Hash] Health status including NATS connection, stream, and version
|
|
126
|
-
|
|
225
|
+
# @raise [HealthCheckFailedError] If skip_cache requested too frequently
|
|
226
|
+
def health_check(skip_cache: false)
|
|
227
|
+
# Rate limit uncached requests to prevent abuse (max 1 per 5 seconds)
|
|
228
|
+
enforce_health_check_rate_limit! if skip_cache
|
|
229
|
+
|
|
127
230
|
start_time = Time.now
|
|
128
231
|
conn_instance = Connection.instance
|
|
129
232
|
|
|
130
233
|
# Active check: calls @jts.account_info internally
|
|
131
|
-
|
|
234
|
+
# Pass skip_cache to force fresh check if requested
|
|
235
|
+
connected = conn_instance.connected?(skip_cache: skip_cache)
|
|
132
236
|
connected_at = conn_instance.connected_at
|
|
237
|
+
connection_state = conn_instance.state
|
|
238
|
+
last_error = conn_instance.last_reconnect_error
|
|
239
|
+
last_error_at = conn_instance.last_reconnect_error_at
|
|
133
240
|
|
|
134
241
|
# Active check: queries actual stream from NATS server
|
|
135
|
-
stream_info = fetch_stream_info
|
|
242
|
+
stream_info = connected ? fetch_stream_info : { exists: false, name: config.stream_name }
|
|
136
243
|
|
|
137
244
|
# Active check: measure NATS round-trip time
|
|
138
245
|
rtt_ms = measure_nats_rtt if connected
|
|
@@ -141,8 +248,13 @@ module JetstreamBridge
|
|
|
141
248
|
|
|
142
249
|
{
|
|
143
250
|
healthy: connected && stream_info&.fetch(:exists, false),
|
|
144
|
-
|
|
145
|
-
|
|
251
|
+
connection: {
|
|
252
|
+
state: connection_state,
|
|
253
|
+
connected: connected,
|
|
254
|
+
connected_at: connected_at&.iso8601,
|
|
255
|
+
last_error: last_error&.message,
|
|
256
|
+
last_error_at: last_error_at&.iso8601
|
|
257
|
+
},
|
|
146
258
|
stream: stream_info,
|
|
147
259
|
performance: {
|
|
148
260
|
nats_rtt_ms: rtt_ms,
|
|
@@ -161,6 +273,10 @@ module JetstreamBridge
|
|
|
161
273
|
rescue StandardError => e
|
|
162
274
|
{
|
|
163
275
|
healthy: false,
|
|
276
|
+
connection: {
|
|
277
|
+
state: :failed,
|
|
278
|
+
connected: false
|
|
279
|
+
},
|
|
164
280
|
error: "#{e.class}: #{e.message}"
|
|
165
281
|
}
|
|
166
282
|
end
|
|
@@ -183,6 +299,8 @@ module JetstreamBridge
|
|
|
183
299
|
|
|
184
300
|
# Convenience method to publish events
|
|
185
301
|
#
|
|
302
|
+
# Automatically establishes connection on first use if not already connected.
|
|
303
|
+
#
|
|
186
304
|
# Supports three usage patterns:
|
|
187
305
|
#
|
|
188
306
|
# 1. Structured parameters (recommended):
|
|
@@ -210,6 +328,7 @@ module JetstreamBridge
|
|
|
210
328
|
# logger.error("Publish failed: #{result.error}")
|
|
211
329
|
# end
|
|
212
330
|
def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **)
|
|
331
|
+
connect_if_needed!
|
|
213
332
|
publisher = Publisher.new
|
|
214
333
|
publisher.publish(event_or_hash, resource_type: resource_type, event_type: event_type, payload: payload,
|
|
215
334
|
subject: subject, **)
|
|
@@ -254,6 +373,8 @@ module JetstreamBridge
|
|
|
254
373
|
|
|
255
374
|
# Convenience method to start consuming messages
|
|
256
375
|
#
|
|
376
|
+
# Automatically establishes connection on first use if not already connected.
|
|
377
|
+
#
|
|
257
378
|
# Supports two usage patterns:
|
|
258
379
|
#
|
|
259
380
|
# 1. With a block (recommended):
|
|
@@ -280,6 +401,7 @@ module JetstreamBridge
|
|
|
280
401
|
# @yield [event] Yields Models::Event object to block
|
|
281
402
|
# @return [Consumer, Thread] Consumer instance or Thread if run: true
|
|
282
403
|
def subscribe(handler = nil, run: false, durable_name: nil, batch_size: nil, &block)
|
|
404
|
+
connect_if_needed!
|
|
283
405
|
handler ||= block
|
|
284
406
|
raise ArgumentError, 'Handler or block required' unless handler
|
|
285
407
|
|
|
@@ -293,52 +415,5 @@ module JetstreamBridge
|
|
|
293
415
|
consumer
|
|
294
416
|
end
|
|
295
417
|
end
|
|
296
|
-
|
|
297
|
-
private
|
|
298
|
-
|
|
299
|
-
def fetch_stream_info
|
|
300
|
-
jts = Connection.jetstream
|
|
301
|
-
info = jts.stream_info(config.stream_name)
|
|
302
|
-
|
|
303
|
-
# Handle both object-style and hash-style access for compatibility
|
|
304
|
-
config_data = info.config
|
|
305
|
-
state_data = info.state
|
|
306
|
-
subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
|
|
307
|
-
messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
|
|
308
|
-
|
|
309
|
-
{
|
|
310
|
-
exists: true,
|
|
311
|
-
name: config.stream_name,
|
|
312
|
-
subjects: subjects,
|
|
313
|
-
messages: messages
|
|
314
|
-
}
|
|
315
|
-
rescue StandardError => e
|
|
316
|
-
{
|
|
317
|
-
exists: false,
|
|
318
|
-
name: config.stream_name,
|
|
319
|
-
error: "#{e.class}: #{e.message}"
|
|
320
|
-
}
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def measure_nats_rtt
|
|
324
|
-
# Measure round-trip time using NATS RTT method
|
|
325
|
-
nc = Connection.nc
|
|
326
|
-
start = Time.now
|
|
327
|
-
nc.rtt
|
|
328
|
-
((Time.now - start) * 1000).round(2)
|
|
329
|
-
rescue StandardError => e
|
|
330
|
-
Logging.warn(
|
|
331
|
-
"Failed to measure NATS RTT: #{e.class} #{e.message}",
|
|
332
|
-
tag: 'JetstreamBridge'
|
|
333
|
-
)
|
|
334
|
-
nil
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def assign!(cfg, key, val)
|
|
338
|
-
setter = :"#{key}="
|
|
339
|
-
raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
|
|
340
|
-
|
|
341
|
-
cfg.public_send(setter, val)
|
|
342
|
-
end
|
|
343
418
|
end
|
|
344
419
|
end
|