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
|
@@ -27,6 +27,15 @@ module JetstreamBridge
|
|
|
27
27
|
class Connection
|
|
28
28
|
include Singleton
|
|
29
29
|
|
|
30
|
+
# Connection states for observability
|
|
31
|
+
module State
|
|
32
|
+
DISCONNECTED = :disconnected
|
|
33
|
+
CONNECTING = :connecting
|
|
34
|
+
CONNECTED = :connected
|
|
35
|
+
RECONNECTING = :reconnecting
|
|
36
|
+
FAILED = :failed
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
DEFAULT_CONN_OPTS = {
|
|
31
40
|
reconnect: true,
|
|
32
41
|
reconnect_time_wait: 2,
|
|
@@ -36,16 +45,21 @@ module JetstreamBridge
|
|
|
36
45
|
|
|
37
46
|
VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
|
|
38
47
|
|
|
48
|
+
# Class-level mutex for thread-safe connection initialization
|
|
49
|
+
# Using class variable to avoid race condition in mutex creation
|
|
50
|
+
# rubocop:disable Style/ClassVars
|
|
51
|
+
@@connection_lock = Mutex.new
|
|
52
|
+
# rubocop:enable Style/ClassVars
|
|
53
|
+
|
|
39
54
|
class << self
|
|
40
55
|
# Thread-safe delegator to the singleton instance.
|
|
41
56
|
# Returns a live JetStream context.
|
|
42
57
|
#
|
|
43
|
-
# Safe to call from multiple threads - uses mutex for synchronization.
|
|
58
|
+
# Safe to call from multiple threads - uses class-level mutex for synchronization.
|
|
44
59
|
#
|
|
45
60
|
# @return [NATS::JetStream::JS] JetStream context
|
|
46
61
|
def connect!
|
|
47
|
-
|
|
48
|
-
@__mutex.synchronize { instance.connect! }
|
|
62
|
+
@@connection_lock.synchronize { instance.connect! }
|
|
49
63
|
end
|
|
50
64
|
|
|
51
65
|
# Optional accessors if callers need raw handles
|
|
@@ -60,12 +74,14 @@ module JetstreamBridge
|
|
|
60
74
|
|
|
61
75
|
# Idempotent: returns an existing, healthy JetStream context or establishes one.
|
|
62
76
|
def connect!
|
|
63
|
-
|
|
77
|
+
# Check if already connected without acquiring mutex (for performance)
|
|
78
|
+
return @jts if @jts && @nc&.connected?
|
|
64
79
|
|
|
65
80
|
servers = nats_servers
|
|
66
81
|
raise 'No NATS URLs configured' if servers.empty?
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
@state = State::CONNECTING
|
|
84
|
+
establish_connection_with_retry(servers)
|
|
69
85
|
|
|
70
86
|
Logging.info(
|
|
71
87
|
"Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
|
|
@@ -77,22 +93,59 @@ module JetstreamBridge
|
|
|
77
93
|
Topology.ensure!(@jts)
|
|
78
94
|
|
|
79
95
|
@connected_at = Time.now.utc
|
|
96
|
+
@state = State::CONNECTED
|
|
80
97
|
@jts
|
|
98
|
+
rescue StandardError
|
|
99
|
+
@state = State::FAILED
|
|
100
|
+
cleanup_connection!
|
|
101
|
+
raise
|
|
81
102
|
end
|
|
82
103
|
|
|
83
104
|
# Public API for checking connection status
|
|
105
|
+
#
|
|
106
|
+
# Uses cached health check result to avoid excessive network calls.
|
|
107
|
+
# Cache expires after 30 seconds.
|
|
108
|
+
#
|
|
109
|
+
# Thread-safe: Cache updates are synchronized to prevent race conditions.
|
|
110
|
+
#
|
|
111
|
+
# @param skip_cache [Boolean] Force fresh health check, bypass cache
|
|
84
112
|
# @return [Boolean] true if NATS client is connected and JetStream is healthy
|
|
85
|
-
def connected?
|
|
113
|
+
def connected?(skip_cache: false)
|
|
86
114
|
return false unless @nc&.connected?
|
|
87
115
|
return false unless @jts
|
|
88
116
|
|
|
89
|
-
|
|
117
|
+
# Use cached result if available and fresh
|
|
118
|
+
now = Time.now.to_i
|
|
119
|
+
return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
|
|
120
|
+
|
|
121
|
+
# Thread-safe cache update to prevent race conditions
|
|
122
|
+
@@connection_lock.synchronize do
|
|
123
|
+
# Double-check after acquiring lock (another thread may have updated)
|
|
124
|
+
now = Time.now.to_i
|
|
125
|
+
return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
|
|
126
|
+
|
|
127
|
+
# Perform actual health check
|
|
128
|
+
@cached_health_status = jetstream_healthy?
|
|
129
|
+
@last_health_check = now
|
|
130
|
+
@cached_health_status
|
|
131
|
+
end
|
|
90
132
|
end
|
|
91
133
|
|
|
92
134
|
# Public API for getting connection timestamp
|
|
93
135
|
# @return [Time, nil] timestamp when connection was established
|
|
94
136
|
attr_reader :connected_at
|
|
95
137
|
|
|
138
|
+
# Get current connection state
|
|
139
|
+
#
|
|
140
|
+
# @return [Symbol] Current connection state (see State module)
|
|
141
|
+
def state
|
|
142
|
+
return State::DISCONNECTED unless @nc
|
|
143
|
+
return State::FAILED if @last_reconnect_error && !@nc.connected?
|
|
144
|
+
return State::RECONNECTING if @reconnecting
|
|
145
|
+
|
|
146
|
+
@nc.connected? ? (@state || State::CONNECTED) : State::DISCONNECTED
|
|
147
|
+
end
|
|
148
|
+
|
|
96
149
|
private
|
|
97
150
|
|
|
98
151
|
def jetstream_healthy?
|
|
@@ -118,19 +171,60 @@ module JetstreamBridge
|
|
|
118
171
|
servers
|
|
119
172
|
end
|
|
120
173
|
|
|
174
|
+
def establish_connection_with_retry(servers)
|
|
175
|
+
attempts = 0
|
|
176
|
+
max_attempts = JetstreamBridge.config.connect_retry_attempts
|
|
177
|
+
retry_delay = JetstreamBridge.config.connect_retry_delay
|
|
178
|
+
|
|
179
|
+
begin
|
|
180
|
+
attempts += 1
|
|
181
|
+
establish_connection(servers)
|
|
182
|
+
rescue ConnectionError => e
|
|
183
|
+
if attempts < max_attempts
|
|
184
|
+
delay = retry_delay * attempts
|
|
185
|
+
Logging.warn(
|
|
186
|
+
"Connection attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
187
|
+
"Retrying in #{delay}s...",
|
|
188
|
+
tag: 'JetstreamBridge::Connection'
|
|
189
|
+
)
|
|
190
|
+
sleep(delay)
|
|
191
|
+
retry
|
|
192
|
+
else
|
|
193
|
+
Logging.error(
|
|
194
|
+
"Failed to establish connection after #{attempts} attempts",
|
|
195
|
+
tag: 'JetstreamBridge::Connection'
|
|
196
|
+
)
|
|
197
|
+
cleanup_connection!
|
|
198
|
+
raise
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
121
203
|
def establish_connection(servers)
|
|
122
|
-
|
|
204
|
+
# Use mock NATS client if explicitly enabled for testing
|
|
205
|
+
# This allows test helpers to inject a mock without affecting normal operation
|
|
206
|
+
@nc = if defined?(JetstreamBridge::TestHelpers) &&
|
|
207
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
208
|
+
JetstreamBridge::TestHelpers.test_mode? &&
|
|
209
|
+
JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
|
|
210
|
+
JetstreamBridge.instance_variable_get(:@mock_nats_client)
|
|
211
|
+
else
|
|
212
|
+
NATS::IO::Client.new
|
|
213
|
+
end
|
|
123
214
|
|
|
124
215
|
# Setup reconnect handler to refresh JetStream context
|
|
125
216
|
@nc.on_reconnect do
|
|
217
|
+
@reconnecting = true
|
|
126
218
|
Logging.info(
|
|
127
219
|
'NATS reconnected, refreshing JetStream context',
|
|
128
220
|
tag: 'JetstreamBridge::Connection'
|
|
129
221
|
)
|
|
130
222
|
refresh_jetstream_context
|
|
223
|
+
@reconnecting = false
|
|
131
224
|
end
|
|
132
225
|
|
|
133
226
|
@nc.on_disconnect do |reason|
|
|
227
|
+
@state = State::DISCONNECTED
|
|
134
228
|
Logging.warn(
|
|
135
229
|
"NATS disconnected: #{reason}",
|
|
136
230
|
tag: 'JetstreamBridge::Connection'
|
|
@@ -144,7 +238,14 @@ module JetstreamBridge
|
|
|
144
238
|
)
|
|
145
239
|
end
|
|
146
240
|
|
|
147
|
-
|
|
241
|
+
# Only connect if not already connected (mock may be pre-connected)
|
|
242
|
+
# Note: For test helpers mock, skip connect. For RSpec mocks, always call connect
|
|
243
|
+
skip_connect = @nc.connected? &&
|
|
244
|
+
defined?(JetstreamBridge::TestHelpers) &&
|
|
245
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
246
|
+
JetstreamBridge::TestHelpers.test_mode?
|
|
247
|
+
|
|
248
|
+
@nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS)) unless skip_connect
|
|
148
249
|
|
|
149
250
|
# Verify connection is established
|
|
150
251
|
verify_connection!
|
|
@@ -255,11 +356,17 @@ module JetstreamBridge
|
|
|
255
356
|
# Verify JetStream is enabled by checking account info
|
|
256
357
|
account_info = @jts.account_info
|
|
257
358
|
|
|
359
|
+
# Handle both object-style and hash-style access for compatibility
|
|
360
|
+
streams = account_info.respond_to?(:streams) ? account_info.streams : account_info[:streams]
|
|
361
|
+
consumers = account_info.respond_to?(:consumers) ? account_info.consumers : account_info[:consumers]
|
|
362
|
+
memory = account_info.respond_to?(:memory) ? account_info.memory : account_info[:memory]
|
|
363
|
+
storage = account_info.respond_to?(:storage) ? account_info.storage : account_info[:storage]
|
|
364
|
+
|
|
258
365
|
Logging.info(
|
|
259
|
-
"JetStream verified - Streams: #{
|
|
260
|
-
"Consumers: #{
|
|
261
|
-
"Memory: #{format_bytes(
|
|
262
|
-
"Storage: #{format_bytes(
|
|
366
|
+
"JetStream verified - Streams: #{streams}, " \
|
|
367
|
+
"Consumers: #{consumers}, " \
|
|
368
|
+
"Memory: #{format_bytes(memory)}, " \
|
|
369
|
+
"Storage: #{format_bytes(storage)}",
|
|
263
370
|
tag: 'JetstreamBridge::Connection'
|
|
264
371
|
)
|
|
265
372
|
rescue NATS::IO::NoRespondersError
|
|
@@ -292,13 +399,40 @@ module JetstreamBridge
|
|
|
292
399
|
|
|
293
400
|
# Re-ensure topology after reconnect
|
|
294
401
|
Topology.ensure!(@jts)
|
|
402
|
+
|
|
403
|
+
# Invalidate health check cache on successful reconnect
|
|
404
|
+
@cached_health_status = nil
|
|
405
|
+
@last_health_check = nil
|
|
406
|
+
|
|
407
|
+
# Clear error state on successful reconnect
|
|
408
|
+
@last_reconnect_error = nil
|
|
409
|
+
@last_reconnect_error_at = nil
|
|
410
|
+
@state = State::CONNECTED
|
|
411
|
+
|
|
412
|
+
Logging.info(
|
|
413
|
+
'JetStream context refreshed successfully after reconnect',
|
|
414
|
+
tag: 'JetstreamBridge::Connection'
|
|
415
|
+
)
|
|
295
416
|
rescue StandardError => e
|
|
417
|
+
# Store error state for diagnostics
|
|
418
|
+
@last_reconnect_error = e
|
|
419
|
+
@last_reconnect_error_at = Time.now
|
|
420
|
+
@state = State::FAILED
|
|
421
|
+
cleanup_connection!(close_nc: false)
|
|
296
422
|
Logging.error(
|
|
297
423
|
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
298
424
|
tag: 'JetstreamBridge::Connection'
|
|
299
425
|
)
|
|
426
|
+
|
|
427
|
+
# Invalidate health check cache to force re-check
|
|
428
|
+
@cached_health_status = false
|
|
429
|
+
@last_health_check = Time.now.to_i
|
|
300
430
|
end
|
|
301
431
|
|
|
432
|
+
# Get last reconnection error for diagnostics
|
|
433
|
+
# @return [StandardError, nil] Last error during reconnection
|
|
434
|
+
attr_reader :last_reconnect_error, :last_reconnect_error_at
|
|
435
|
+
|
|
302
436
|
# Expose for class-level helpers (not part of public API)
|
|
303
437
|
attr_reader :nc
|
|
304
438
|
|
|
@@ -312,5 +446,20 @@ module JetstreamBridge
|
|
|
312
446
|
def sanitize_urls(urls)
|
|
313
447
|
urls.map { |u| Logging.sanitize_url(u) }
|
|
314
448
|
end
|
|
449
|
+
|
|
450
|
+
def cleanup_connection!(close_nc: true)
|
|
451
|
+
begin
|
|
452
|
+
# Avoid touching RSpec doubles used in unit tests
|
|
453
|
+
is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
|
|
454
|
+
@nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
|
|
455
|
+
rescue StandardError
|
|
456
|
+
# ignore cleanup errors
|
|
457
|
+
end
|
|
458
|
+
@nc = nil
|
|
459
|
+
@jts = nil
|
|
460
|
+
@cached_health_status = nil
|
|
461
|
+
@last_health_check = nil
|
|
462
|
+
@connected_at = nil
|
|
463
|
+
end
|
|
315
464
|
end
|
|
316
465
|
end
|
|
@@ -101,15 +101,21 @@ module JetstreamBridge
|
|
|
101
101
|
|
|
102
102
|
# Get processing statistics
|
|
103
103
|
#
|
|
104
|
-
#
|
|
104
|
+
# Uses a single aggregated query to avoid N+1 problem.
|
|
105
|
+
#
|
|
106
|
+
# @return [Hash] Statistics hash with counts by status
|
|
105
107
|
def processing_stats
|
|
106
108
|
return {} unless has_column?(:status)
|
|
107
109
|
|
|
110
|
+
# Single aggregated query instead of 4 separate queries
|
|
111
|
+
stats_by_status = group(:status).count
|
|
112
|
+
total_count = stats_by_status.values.sum
|
|
113
|
+
|
|
108
114
|
{
|
|
109
|
-
total:
|
|
110
|
-
processed: processed
|
|
111
|
-
failed: failed
|
|
112
|
-
pending:
|
|
115
|
+
total: total_count,
|
|
116
|
+
processed: stats_by_status['processed'] || 0,
|
|
117
|
+
failed: stats_by_status['failed'] || 0,
|
|
118
|
+
pending: stats_by_status['pending'] || stats_by_status[nil] || 0
|
|
113
119
|
}
|
|
114
120
|
end
|
|
115
121
|
end
|
|
@@ -168,8 +174,8 @@ module JetstreamBridge
|
|
|
168
174
|
def raise_missing_ar!(which, method_name)
|
|
169
175
|
raise(
|
|
170
176
|
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
|
171
|
-
|
|
172
|
-
|
|
177
|
+
'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
|
|
178
|
+
'`gem "activerecord"` to your Gemfile.'
|
|
173
179
|
)
|
|
174
180
|
end
|
|
175
181
|
end
|
|
@@ -169,8 +169,8 @@ module JetstreamBridge
|
|
|
169
169
|
def raise_missing_ar!(which, method_name)
|
|
170
170
|
raise(
|
|
171
171
|
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
'Enable `use_outbox` only in apps with ActiveRecord, or add ' \
|
|
173
|
+
'`gem "activerecord"` to your Gemfile.'
|
|
174
174
|
)
|
|
175
175
|
end
|
|
176
176
|
end
|
|
@@ -36,11 +36,16 @@ module JetstreamBridge
|
|
|
36
36
|
class Publisher
|
|
37
37
|
# Initialize a new Publisher instance.
|
|
38
38
|
#
|
|
39
|
+
# Note: The NATS connection should already be established via JetstreamBridge.startup!
|
|
40
|
+
# or automatically on first use. This assumes the connection is already established.
|
|
41
|
+
#
|
|
39
42
|
# @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
|
|
40
43
|
# Defaults to PublisherRetryStrategy with exponential backoff.
|
|
41
|
-
# @raise [ConnectionError] If unable to
|
|
44
|
+
# @raise [ConnectionError] If unable to get JetStream connection
|
|
42
45
|
def initialize(retry_strategy: nil)
|
|
43
|
-
@jts = Connection.
|
|
46
|
+
@jts = Connection.jetstream
|
|
47
|
+
raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
|
|
48
|
+
|
|
44
49
|
@retry_strategy = retry_strategy || PublisherRetryStrategy.new
|
|
45
50
|
end
|
|
46
51
|
|
|
@@ -103,7 +108,7 @@ module JetstreamBridge
|
|
|
103
108
|
# end
|
|
104
109
|
#
|
|
105
110
|
def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
|
|
106
|
-
|
|
111
|
+
ensure_destination_app_configured!
|
|
107
112
|
|
|
108
113
|
params = { event_or_hash: event_or_hash, resource_type: resource_type, event_type: event_type,
|
|
109
114
|
payload: payload, subject: subject, options: options }
|
|
@@ -186,7 +191,7 @@ module JetstreamBridge
|
|
|
186
191
|
normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
|
|
187
192
|
end
|
|
188
193
|
|
|
189
|
-
def
|
|
194
|
+
def ensure_destination_app_configured!
|
|
190
195
|
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
|
191
196
|
|
|
192
197
|
raise ArgumentError, 'destination_app must be configured'
|
|
@@ -290,7 +295,7 @@ module JetstreamBridge
|
|
|
290
295
|
'schema_version' => 1,
|
|
291
296
|
'event_type' => event_type,
|
|
292
297
|
'producer' => JetstreamBridge.config.app_name,
|
|
293
|
-
'resource_id' => (payload
|
|
298
|
+
'resource_id' => extract_resource_id(payload),
|
|
294
299
|
'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
|
|
295
300
|
'trace_id' => options[:trace_id] || SecureRandom.hex(8),
|
|
296
301
|
'resource_type' => resource_type,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/model_codec_setup'
|
|
4
|
+
require_relative '../core/logging'
|
|
5
|
+
require_relative '../core/connection'
|
|
6
|
+
|
|
7
|
+
module JetstreamBridge
|
|
8
|
+
module Rails
|
|
9
|
+
# Rails-specific lifecycle helpers for JetStream Bridge.
|
|
10
|
+
#
|
|
11
|
+
# Keeps the Railtie thin and makes lifecycle decisions easy to test and reason about.
|
|
12
|
+
module Integration
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Configure logger to use Rails.logger when available.
|
|
16
|
+
def configure_logger!
|
|
17
|
+
JetstreamBridge.configure do |config|
|
|
18
|
+
config.logger ||= ::Rails.logger if defined?(::Rails.logger)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Attach ActiveRecord hooks for serializer setup on reload.
|
|
23
|
+
def attach_active_record_hooks!
|
|
24
|
+
ActiveSupport.on_load(:active_record) do
|
|
25
|
+
ActiveSupport::Reloader.to_prepare { JetstreamBridge::ModelCodecSetup.apply! }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Validate config, enable test mode if appropriate, and start the bridge unless auto-start is disabled.
|
|
30
|
+
def boot_bridge!
|
|
31
|
+
auto_enable_test_mode!
|
|
32
|
+
|
|
33
|
+
if autostart_disabled?
|
|
34
|
+
message = "Auto-start skipped (reason: #{autostart_skip_reason}; " \
|
|
35
|
+
'enable via lazy_connect=false, unset JETSTREAM_BRIDGE_DISABLE_AUTOSTART, ' \
|
|
36
|
+
'or set JETSTREAM_BRIDGE_FORCE_AUTOSTART=1)'
|
|
37
|
+
Logging.info(message, tag: 'JetstreamBridge::Railtie')
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
JetstreamBridge.config.validate!
|
|
42
|
+
JetstreamBridge.startup!
|
|
43
|
+
log_started!
|
|
44
|
+
log_development_connection_details! if rails_development?
|
|
45
|
+
register_shutdown_hook!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Auto-enable test mode in test environment when NATS is not configured.
|
|
49
|
+
def auto_enable_test_mode!
|
|
50
|
+
return unless auto_enable_test_mode?
|
|
51
|
+
|
|
52
|
+
Logging.info(
|
|
53
|
+
'[JetStream Bridge] Auto-enabling test mode (NATS_URLS not set)',
|
|
54
|
+
tag: 'JetstreamBridge::Railtie'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
require_relative '../test_helpers'
|
|
58
|
+
JetstreamBridge::TestHelpers.enable_test_mode!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def auto_enable_test_mode?
|
|
62
|
+
rails_test? &&
|
|
63
|
+
ENV['NATS_URLS'].to_s.strip.empty? &&
|
|
64
|
+
!(defined?(JetstreamBridge::TestHelpers) &&
|
|
65
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
66
|
+
JetstreamBridge::TestHelpers.test_mode?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def autostart_disabled?
|
|
70
|
+
return false if force_autostart?
|
|
71
|
+
|
|
72
|
+
JetstreamBridge.config.lazy_connect ||
|
|
73
|
+
env_disables_autostart? ||
|
|
74
|
+
skip_autostart_for_rails_tooling?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def autostart_skip_reason
|
|
78
|
+
return 'lazy_connect enabled' if JetstreamBridge.config.lazy_connect
|
|
79
|
+
return 'JETSTREAM_BRIDGE_DISABLE_AUTOSTART set' if env_disables_autostart?
|
|
80
|
+
return 'Rails console' if rails_console?
|
|
81
|
+
return 'rake task' if rake_task?
|
|
82
|
+
|
|
83
|
+
'unknown'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def env_disables_autostart?
|
|
87
|
+
value = ENV.fetch('JETSTREAM_BRIDGE_DISABLE_AUTOSTART', nil)
|
|
88
|
+
return false if value.nil?
|
|
89
|
+
|
|
90
|
+
normalized = value.to_s.strip.downcase
|
|
91
|
+
return false if normalized.empty?
|
|
92
|
+
|
|
93
|
+
!%w[false 0 no off].include?(normalized)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def force_autostart?
|
|
97
|
+
value = ENV.fetch('JETSTREAM_BRIDGE_FORCE_AUTOSTART', nil)
|
|
98
|
+
return false if value.nil?
|
|
99
|
+
|
|
100
|
+
normalized = value.to_s.strip.downcase
|
|
101
|
+
%w[true 1 yes on].include?(normalized)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def log_started!
|
|
105
|
+
active_logger&.info('[JetStream Bridge] Started successfully')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def log_development_connection_details!
|
|
109
|
+
conn_state = JetstreamBridge::Connection.instance.state
|
|
110
|
+
active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
|
|
111
|
+
active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
|
|
112
|
+
active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
|
|
113
|
+
active_logger&.info("[JetStream Bridge] Publishing to: #{JetstreamBridge.config.source_subject}")
|
|
114
|
+
active_logger&.info("[JetStream Bridge] Consuming from: #{JetstreamBridge.config.destination_subject}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def register_shutdown_hook!
|
|
118
|
+
return if @shutdown_hook_registered
|
|
119
|
+
|
|
120
|
+
at_exit { JetstreamBridge.shutdown! }
|
|
121
|
+
@shutdown_hook_registered = true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def rails_test?
|
|
125
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def rails_development?
|
|
129
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.development?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def rails_console?
|
|
133
|
+
!!defined?(::Rails::Console)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def rake_task?
|
|
137
|
+
!!defined?(::Rake) || File.basename($PROGRAM_NAME) == 'rake'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def skip_autostart_for_rails_tooling?
|
|
141
|
+
rails_console? || rake_task?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def active_logger
|
|
145
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
146
|
+
::Rails.logger
|
|
147
|
+
else
|
|
148
|
+
Logging.logger
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'integration'
|
|
4
|
+
|
|
5
|
+
module JetstreamBridge
|
|
6
|
+
# Rails integration for JetStream Bridge.
|
|
7
|
+
#
|
|
8
|
+
# This Railtie integrates JetStream Bridge with the Rails application lifecycle:
|
|
9
|
+
# - Configuration: Logger is configured early in the Rails boot process
|
|
10
|
+
# - Startup: Connection is established after user initializers load (explicit startup!)
|
|
11
|
+
# - Shutdown: Connection is closed when Rails shuts down (at_exit hook)
|
|
12
|
+
# - Restart: Puma/Unicorn workers get fresh connections on fork
|
|
13
|
+
#
|
|
14
|
+
class Railtie < ::Rails::Railtie
|
|
15
|
+
# Set up logger to use Rails.logger by default
|
|
16
|
+
# Note: This only configures the logger, does NOT establish connection
|
|
17
|
+
initializer 'jetstream_bridge.logger', before: :initialize_logger do
|
|
18
|
+
JetstreamBridge::Rails::Integration.configure_logger!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Load ActiveRecord model tweaks after ActiveRecord is loaded
|
|
22
|
+
initializer 'jetstream_bridge.active_record', after: 'active_record.initialize_database' do
|
|
23
|
+
JetstreamBridge::Rails::Integration.attach_active_record_hooks!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Establish connection after Rails initialization is complete
|
|
27
|
+
# This runs after all user initializers have loaded
|
|
28
|
+
config.after_initialize do
|
|
29
|
+
JetstreamBridge::Rails::Integration.boot_bridge!
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Add console helper methods
|
|
33
|
+
console do
|
|
34
|
+
::Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
|
|
35
|
+
::Rails.logger.info '[JetStream Bridge] Console helpers available:'
|
|
36
|
+
::Rails.logger.info ' JetstreamBridge.health_check - Check connection status'
|
|
37
|
+
::Rails.logger.info ' JetstreamBridge.stream_info - View stream details'
|
|
38
|
+
::Rails.logger.info ' JetstreamBridge.connected? - Check if connected'
|
|
39
|
+
::Rails.logger.info ' JetstreamBridge.shutdown! - Gracefully disconnect'
|
|
40
|
+
::Rails.logger.info ' JetstreamBridge.reconnect! - Reconnect (useful after configuration changes)'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Load rake tasks
|
|
44
|
+
rake_tasks do
|
|
45
|
+
load File.expand_path('../tasks/install.rake', __dir__)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Add generators
|
|
49
|
+
generators do
|
|
50
|
+
require 'generators/jetstream_bridge/health_check/health_check_generator'
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -85,7 +85,7 @@ namespace :jetstream_bridge do
|
|
|
85
85
|
puts '[jetstream_bridge] Testing NATS connection...'
|
|
86
86
|
|
|
87
87
|
begin
|
|
88
|
-
jts = JetstreamBridge.
|
|
88
|
+
jts = JetstreamBridge.connect_and_ensure_stream!
|
|
89
89
|
puts '✓ Successfully connected to NATS'
|
|
90
90
|
puts '✓ JetStream is available'
|
|
91
91
|
puts '✓ Stream topology ensured'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# Common fixtures for quickly building events in specs.
|
|
6
|
+
module Fixtures
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Build a user.created event
|
|
10
|
+
def user_created_event(attrs = {})
|
|
11
|
+
JetstreamBridge::TestHelpers.build_jetstream_event(
|
|
12
|
+
event_type: 'user.created',
|
|
13
|
+
payload: {
|
|
14
|
+
id: attrs[:id] || 1,
|
|
15
|
+
email: attrs[:email] || 'test@example.com',
|
|
16
|
+
name: attrs[:name] || 'Test User'
|
|
17
|
+
}.merge(attrs[:payload] || {})
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Build multiple sample events
|
|
22
|
+
def sample_events(count = 3, type: 'test.event')
|
|
23
|
+
Array.new(count) do |i|
|
|
24
|
+
JetstreamBridge::TestHelpers.build_jetstream_event(
|
|
25
|
+
event_type: type,
|
|
26
|
+
payload: { id: i + 1, sequence: i }
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build a generic event with custom attributes
|
|
32
|
+
def event(event_type:, payload: {}, **attrs)
|
|
33
|
+
JetstreamBridge::TestHelpers.build_jetstream_event(
|
|
34
|
+
event_type: event_type,
|
|
35
|
+
payload: payload,
|
|
36
|
+
**attrs
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|