jetstream_bridge 4.5.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +338 -87
- data/README.md +3 -13
- data/docs/GETTING_STARTED.md +8 -12
- data/docs/PRODUCTION.md +13 -35
- data/docs/RESTRICTED_PERMISSIONS.md +399 -0
- data/docs/TESTING.md +33 -22
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
- data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
- data/lib/jetstream_bridge/core/config.rb +32 -161
- data/lib/jetstream_bridge/core/connection.rb +508 -0
- data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
- data/lib/jetstream_bridge/core.rb +2 -0
- data/lib/jetstream_bridge/models/subject.rb +15 -23
- data/lib/jetstream_bridge/provisioner.rb +67 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
- data/lib/jetstream_bridge/rails/integration.rb +5 -8
- data/lib/jetstream_bridge/rails/railtie.rb +3 -4
- data/lib/jetstream_bridge/tasks/install.rake +17 -1
- data/lib/jetstream_bridge/topology/topology.rb +1 -6
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +345 -202
- metadata +8 -8
- data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
- data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
- data/lib/jetstream_bridge/core/health_checker.rb +0 -184
- data/lib/jetstream_bridge/facade.rb +0 -212
- data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +0 -110
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nats/io/client'
|
|
4
|
+
require 'singleton'
|
|
5
|
+
require 'oj'
|
|
6
|
+
require_relative 'duration'
|
|
7
|
+
require_relative 'logging'
|
|
8
|
+
require_relative 'config'
|
|
9
|
+
require_relative '../topology/topology'
|
|
10
|
+
|
|
11
|
+
module JetstreamBridge
|
|
12
|
+
# Singleton connection to NATS with thread-safe initialization.
|
|
13
|
+
#
|
|
14
|
+
# This class manages a single NATS connection for the entire application,
|
|
15
|
+
# ensuring thread-safe access in multi-threaded environments like Rails
|
|
16
|
+
# with Puma or Sidekiq.
|
|
17
|
+
#
|
|
18
|
+
# Thread Safety:
|
|
19
|
+
# - Connection initialization is synchronized with a mutex
|
|
20
|
+
# - The singleton pattern ensures only one connection instance exists
|
|
21
|
+
# - Safe to call from multiple threads/workers simultaneously
|
|
22
|
+
#
|
|
23
|
+
# Example:
|
|
24
|
+
# # Safe from any thread
|
|
25
|
+
# jts = JetstreamBridge::Connection.connect!
|
|
26
|
+
# jts.publish(...)
|
|
27
|
+
class Connection
|
|
28
|
+
include Singleton
|
|
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
|
+
|
|
39
|
+
DEFAULT_CONN_OPTS = {
|
|
40
|
+
reconnect: true,
|
|
41
|
+
reconnect_time_wait: 2,
|
|
42
|
+
max_reconnect_attempts: 10,
|
|
43
|
+
connect_timeout: 5
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
|
|
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
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Thread-safe delegator to the singleton instance.
|
|
56
|
+
# Returns a live JetStream context.
|
|
57
|
+
#
|
|
58
|
+
# Safe to call from multiple threads - uses class-level mutex for synchronization.
|
|
59
|
+
#
|
|
60
|
+
# @return [NATS::JetStream::JS] JetStream context
|
|
61
|
+
def connect!(verify_js: nil)
|
|
62
|
+
@@connection_lock.synchronize { instance.connect!(verify_js: verify_js) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Optional accessors if callers need raw handles
|
|
66
|
+
def nc
|
|
67
|
+
instance.__send__(:nc)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def jetstream
|
|
71
|
+
instance.__send__(:jetstream)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Idempotent: returns an existing, healthy JetStream context or establishes one.
|
|
76
|
+
def connect!(verify_js: nil)
|
|
77
|
+
verify_js = config_auto_provision if verify_js.nil?
|
|
78
|
+
# Check if already connected without acquiring mutex (for performance)
|
|
79
|
+
return @jts if @jts && @nc&.connected?
|
|
80
|
+
|
|
81
|
+
servers = nats_servers
|
|
82
|
+
raise 'No NATS URLs configured' if servers.empty?
|
|
83
|
+
|
|
84
|
+
@state = State::CONNECTING
|
|
85
|
+
establish_connection_with_retry(servers, verify_js: verify_js)
|
|
86
|
+
|
|
87
|
+
Logging.info(
|
|
88
|
+
"Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
|
|
89
|
+
"#{sanitize_urls(servers).join(', ')}",
|
|
90
|
+
tag: 'JetstreamBridge::Connection'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@connected_at = Time.now.utc
|
|
94
|
+
@state = State::CONNECTED
|
|
95
|
+
@jts
|
|
96
|
+
rescue StandardError
|
|
97
|
+
@state = State::FAILED
|
|
98
|
+
cleanup_connection!
|
|
99
|
+
raise
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Public API for checking connection status
|
|
103
|
+
#
|
|
104
|
+
# Uses cached health check result to avoid excessive network calls.
|
|
105
|
+
# Cache expires after 30 seconds.
|
|
106
|
+
#
|
|
107
|
+
# Thread-safe: Cache updates are synchronized to prevent race conditions.
|
|
108
|
+
#
|
|
109
|
+
# @param skip_cache [Boolean] Force fresh health check, bypass cache
|
|
110
|
+
# @return [Boolean] true if NATS client is connected and JetStream is healthy
|
|
111
|
+
def connected?(skip_cache: false)
|
|
112
|
+
return false unless @nc&.connected?
|
|
113
|
+
return false unless @jts
|
|
114
|
+
|
|
115
|
+
# Use cached result if available and fresh
|
|
116
|
+
now = Time.now.to_i
|
|
117
|
+
return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
|
|
118
|
+
|
|
119
|
+
# Thread-safe cache update to prevent race conditions
|
|
120
|
+
@@connection_lock.synchronize do
|
|
121
|
+
# Double-check after acquiring lock (another thread may have updated)
|
|
122
|
+
now = Time.now.to_i
|
|
123
|
+
return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
|
|
124
|
+
|
|
125
|
+
# Perform actual health check (management APIs optional)
|
|
126
|
+
@cached_health_status = jetstream_healthy?(verify_js: config_auto_provision)
|
|
127
|
+
@last_health_check = now
|
|
128
|
+
@cached_health_status
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Public API for getting connection timestamp
|
|
133
|
+
# @return [Time, nil] timestamp when connection was established
|
|
134
|
+
attr_reader :connected_at
|
|
135
|
+
|
|
136
|
+
# Last reconnection error metadata (exposed for health checks/diagnostics)
|
|
137
|
+
attr_reader :last_reconnect_error, :last_reconnect_error_at
|
|
138
|
+
|
|
139
|
+
# Get current connection state
|
|
140
|
+
#
|
|
141
|
+
# @return [Symbol] Current connection state (see State module)
|
|
142
|
+
def state
|
|
143
|
+
return State::DISCONNECTED unless @nc
|
|
144
|
+
return State::FAILED if @last_reconnect_error && !@nc.connected?
|
|
145
|
+
return State::RECONNECTING if @reconnecting
|
|
146
|
+
|
|
147
|
+
@nc.connected? ? (@state || State::CONNECTED) : State::DISCONNECTED
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def jetstream_healthy?(verify_js:)
|
|
153
|
+
# Lightweight health when management APIs are disabled
|
|
154
|
+
return ping_only_health unless verify_js
|
|
155
|
+
|
|
156
|
+
# Verify JetStream responds to simple API call
|
|
157
|
+
@jts.account_info
|
|
158
|
+
true
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
Logging.warn(
|
|
161
|
+
"JetStream health check failed: #{e.class} #{e.message}",
|
|
162
|
+
tag: 'JetstreamBridge::Connection'
|
|
163
|
+
)
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def ping_only_health
|
|
168
|
+
return false unless @nc&.connected?
|
|
169
|
+
|
|
170
|
+
# Flush acts as a ping/pong round-trip without hitting JetStream management subjects
|
|
171
|
+
@nc.flush(0.5)
|
|
172
|
+
true
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
Logging.warn(
|
|
175
|
+
"NATS connectivity check failed: #{e.class} #{e.message}",
|
|
176
|
+
tag: 'JetstreamBridge::Connection'
|
|
177
|
+
)
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def nats_servers
|
|
182
|
+
servers = JetstreamBridge.config.nats_urls
|
|
183
|
+
.to_s
|
|
184
|
+
.split(',')
|
|
185
|
+
.map(&:strip)
|
|
186
|
+
.reject(&:empty?)
|
|
187
|
+
|
|
188
|
+
validate_nats_urls!(servers)
|
|
189
|
+
servers
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def establish_connection_with_retry(servers, verify_js:)
|
|
193
|
+
attempts = 0
|
|
194
|
+
max_attempts = JetstreamBridge.config.connect_retry_attempts
|
|
195
|
+
retry_delay = JetstreamBridge.config.connect_retry_delay
|
|
196
|
+
|
|
197
|
+
begin
|
|
198
|
+
attempts += 1
|
|
199
|
+
establish_connection(servers, verify_js: verify_js)
|
|
200
|
+
rescue ConnectionError => e
|
|
201
|
+
if attempts < max_attempts
|
|
202
|
+
delay = retry_delay * attempts
|
|
203
|
+
Logging.warn(
|
|
204
|
+
"Connection attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
205
|
+
"Retrying in #{delay}s...",
|
|
206
|
+
tag: 'JetstreamBridge::Connection'
|
|
207
|
+
)
|
|
208
|
+
sleep(delay)
|
|
209
|
+
retry
|
|
210
|
+
else
|
|
211
|
+
Logging.error(
|
|
212
|
+
"Failed to establish connection after #{attempts} attempts",
|
|
213
|
+
tag: 'JetstreamBridge::Connection'
|
|
214
|
+
)
|
|
215
|
+
cleanup_connection!
|
|
216
|
+
raise
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def establish_connection(servers, verify_js:)
|
|
222
|
+
# Use mock NATS client if explicitly enabled for testing
|
|
223
|
+
# This allows test helpers to inject a mock without affecting normal operation
|
|
224
|
+
@nc = if defined?(JetstreamBridge::TestHelpers) &&
|
|
225
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
226
|
+
JetstreamBridge::TestHelpers.test_mode? &&
|
|
227
|
+
JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
|
|
228
|
+
JetstreamBridge.instance_variable_get(:@mock_nats_client)
|
|
229
|
+
else
|
|
230
|
+
NATS::IO::Client.new
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Setup reconnect handler to refresh JetStream context
|
|
234
|
+
@nc.on_reconnect do
|
|
235
|
+
@reconnecting = true
|
|
236
|
+
Logging.info(
|
|
237
|
+
'NATS reconnected, refreshing JetStream context',
|
|
238
|
+
tag: 'JetstreamBridge::Connection'
|
|
239
|
+
)
|
|
240
|
+
refresh_jetstream_context
|
|
241
|
+
@reconnecting = false
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
@nc.on_disconnect do |reason|
|
|
245
|
+
@state = State::DISCONNECTED
|
|
246
|
+
Logging.warn(
|
|
247
|
+
"NATS disconnected: #{reason}",
|
|
248
|
+
tag: 'JetstreamBridge::Connection'
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
@nc.on_error do |err|
|
|
253
|
+
Logging.error(
|
|
254
|
+
"NATS error: #{err}",
|
|
255
|
+
tag: 'JetstreamBridge::Connection'
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Only connect if not already connected (mock may be pre-connected)
|
|
260
|
+
# Note: For test helpers mock, skip connect. For RSpec mocks, always call connect
|
|
261
|
+
skip_connect = @nc.connected? &&
|
|
262
|
+
defined?(JetstreamBridge::TestHelpers) &&
|
|
263
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
264
|
+
JetstreamBridge::TestHelpers.test_mode?
|
|
265
|
+
|
|
266
|
+
@nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS)) unless skip_connect
|
|
267
|
+
|
|
268
|
+
# Verify connection is established
|
|
269
|
+
verify_connection!
|
|
270
|
+
|
|
271
|
+
# Create JetStream context
|
|
272
|
+
@jts = @nc.jetstream
|
|
273
|
+
|
|
274
|
+
# Verify JetStream is available
|
|
275
|
+
if verify_js
|
|
276
|
+
verify_jetstream!
|
|
277
|
+
if config_auto_provision
|
|
278
|
+
Topology.ensure!(@jts)
|
|
279
|
+
Logging.info(
|
|
280
|
+
'Topology ensured after connection (auto_provision=true).',
|
|
281
|
+
tag: 'JetstreamBridge::Connection'
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
else
|
|
285
|
+
Logging.info(
|
|
286
|
+
'Skipping JetStream account_info verification (auto_provision=false). ' \
|
|
287
|
+
'Assuming JetStream is enabled.',
|
|
288
|
+
tag: 'JetstreamBridge::Connection'
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Ensure JetStream responds to #nc
|
|
293
|
+
return if @jts.respond_to?(:nc)
|
|
294
|
+
|
|
295
|
+
nc_ref = @nc
|
|
296
|
+
@jts.define_singleton_method(:nc) { nc_ref }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def validate_nats_urls!(servers)
|
|
300
|
+
Logging.debug(
|
|
301
|
+
"Validating #{servers.size} NATS URL(s): #{sanitize_urls(servers).join(', ')}",
|
|
302
|
+
tag: 'JetstreamBridge::Connection'
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
servers.each do |url|
|
|
306
|
+
# Check for basic URL format (scheme://host)
|
|
307
|
+
unless url.include?('://')
|
|
308
|
+
Logging.error(
|
|
309
|
+
"Invalid URL format (missing scheme): #{url}",
|
|
310
|
+
tag: 'JetstreamBridge::Connection'
|
|
311
|
+
)
|
|
312
|
+
raise ConnectionError, "Invalid NATS URL format: #{url}. Expected format: nats://host:port"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
uri = URI.parse(url)
|
|
316
|
+
|
|
317
|
+
# Validate scheme
|
|
318
|
+
scheme = uri.scheme&.downcase
|
|
319
|
+
unless VALID_NATS_SCHEMES.include?(scheme)
|
|
320
|
+
Logging.error(
|
|
321
|
+
"Invalid URL scheme '#{uri.scheme}': #{Logging.sanitize_url(url)}",
|
|
322
|
+
tag: 'JetstreamBridge::Connection'
|
|
323
|
+
)
|
|
324
|
+
raise ConnectionError, "Invalid NATS URL scheme '#{uri.scheme}' in: #{url}. Expected 'nats' or 'nats+tls'"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Validate host is present
|
|
328
|
+
if uri.host.nil? || uri.host.empty?
|
|
329
|
+
Logging.error(
|
|
330
|
+
"Missing host in URL: #{Logging.sanitize_url(url)}",
|
|
331
|
+
tag: 'JetstreamBridge::Connection'
|
|
332
|
+
)
|
|
333
|
+
raise ConnectionError, "Invalid NATS URL - missing host: #{url}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Validate port if present
|
|
337
|
+
if uri.port && (uri.port < 1 || uri.port > 65_535)
|
|
338
|
+
Logging.error(
|
|
339
|
+
"Invalid port #{uri.port} in URL: #{Logging.sanitize_url(url)}",
|
|
340
|
+
tag: 'JetstreamBridge::Connection'
|
|
341
|
+
)
|
|
342
|
+
raise ConnectionError, "Invalid NATS URL - port must be 1-65535: #{url}"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
Logging.debug(
|
|
346
|
+
"URL validated: #{Logging.sanitize_url(url)}",
|
|
347
|
+
tag: 'JetstreamBridge::Connection'
|
|
348
|
+
)
|
|
349
|
+
rescue URI::InvalidURIError => e
|
|
350
|
+
Logging.error(
|
|
351
|
+
"Malformed URL: #{url} (#{e.message})",
|
|
352
|
+
tag: 'JetstreamBridge::Connection'
|
|
353
|
+
)
|
|
354
|
+
raise ConnectionError, "Invalid NATS URL format: #{url} (#{e.message})"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
Logging.info(
|
|
358
|
+
'All NATS URLs validated successfully',
|
|
359
|
+
tag: 'JetstreamBridge::Connection'
|
|
360
|
+
)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def verify_connection!
|
|
364
|
+
Logging.debug(
|
|
365
|
+
'Verifying NATS connection...',
|
|
366
|
+
tag: 'JetstreamBridge::Connection'
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
unless @nc.connected?
|
|
370
|
+
Logging.error(
|
|
371
|
+
'NATS connection verification failed - client not connected',
|
|
372
|
+
tag: 'JetstreamBridge::Connection'
|
|
373
|
+
)
|
|
374
|
+
raise ConnectionError, 'Failed to establish connection to NATS server(s)'
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
Logging.info(
|
|
378
|
+
'NATS connection verified successfully',
|
|
379
|
+
tag: 'JetstreamBridge::Connection'
|
|
380
|
+
)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def verify_jetstream!
|
|
384
|
+
Logging.debug(
|
|
385
|
+
'Verifying JetStream availability...',
|
|
386
|
+
tag: 'JetstreamBridge::Connection'
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Verify JetStream is enabled by checking account info
|
|
390
|
+
account_info = @jts.account_info
|
|
391
|
+
|
|
392
|
+
# Handle both object-style and hash-style access for compatibility
|
|
393
|
+
streams = account_info.respond_to?(:streams) ? account_info.streams : account_info[:streams]
|
|
394
|
+
consumers = account_info.respond_to?(:consumers) ? account_info.consumers : account_info[:consumers]
|
|
395
|
+
memory = account_info.respond_to?(:memory) ? account_info.memory : account_info[:memory]
|
|
396
|
+
storage = account_info.respond_to?(:storage) ? account_info.storage : account_info[:storage]
|
|
397
|
+
|
|
398
|
+
Logging.info(
|
|
399
|
+
"JetStream verified - Streams: #{streams}, " \
|
|
400
|
+
"Consumers: #{consumers}, " \
|
|
401
|
+
"Memory: #{format_bytes(memory)}, " \
|
|
402
|
+
"Storage: #{format_bytes(storage)}",
|
|
403
|
+
tag: 'JetstreamBridge::Connection'
|
|
404
|
+
)
|
|
405
|
+
rescue NATS::IO::NoRespondersError
|
|
406
|
+
Logging.error(
|
|
407
|
+
'JetStream not available - no responders (JetStream not enabled)',
|
|
408
|
+
tag: 'JetstreamBridge::Connection'
|
|
409
|
+
)
|
|
410
|
+
raise ConnectionError, 'JetStream not enabled on NATS server. Please enable JetStream with -js flag'
|
|
411
|
+
rescue StandardError => e
|
|
412
|
+
Logging.error(
|
|
413
|
+
"JetStream verification failed: #{e.class} - #{e.message}",
|
|
414
|
+
tag: 'JetstreamBridge::Connection'
|
|
415
|
+
)
|
|
416
|
+
raise ConnectionError, "JetStream verification failed: #{e.message}"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def format_bytes(bytes)
|
|
420
|
+
return 'N/A' if bytes.nil? || bytes.zero?
|
|
421
|
+
|
|
422
|
+
units = %w[B KB MB GB TB]
|
|
423
|
+
exp = (Math.log(bytes) / Math.log(1024)).to_i
|
|
424
|
+
exp = [exp, units.length - 1].min
|
|
425
|
+
"#{(bytes / (1024.0**exp)).round(2)} #{units[exp]}"
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def refresh_jetstream_context
|
|
429
|
+
@jts = @nc.jetstream
|
|
430
|
+
nc_ref = @nc
|
|
431
|
+
@jts.define_singleton_method(:nc) { nc_ref } unless @jts.respond_to?(:nc)
|
|
432
|
+
|
|
433
|
+
# Re-ensure topology after reconnect when allowed
|
|
434
|
+
if config_auto_provision
|
|
435
|
+
Topology.ensure!(@jts)
|
|
436
|
+
else
|
|
437
|
+
Logging.info(
|
|
438
|
+
'Skipping topology provisioning after reconnect (auto_provision=false).',
|
|
439
|
+
tag: 'JetstreamBridge::Connection'
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Invalidate health check cache on successful reconnect
|
|
444
|
+
@cached_health_status = nil
|
|
445
|
+
@last_health_check = nil
|
|
446
|
+
|
|
447
|
+
# Clear error state on successful reconnect
|
|
448
|
+
@last_reconnect_error = nil
|
|
449
|
+
@last_reconnect_error_at = nil
|
|
450
|
+
@state = State::CONNECTED
|
|
451
|
+
|
|
452
|
+
Logging.info(
|
|
453
|
+
'JetStream context refreshed successfully after reconnect',
|
|
454
|
+
tag: 'JetstreamBridge::Connection'
|
|
455
|
+
)
|
|
456
|
+
rescue StandardError => e
|
|
457
|
+
# Store error state for diagnostics
|
|
458
|
+
@last_reconnect_error = e
|
|
459
|
+
@last_reconnect_error_at = Time.now
|
|
460
|
+
@state = State::FAILED
|
|
461
|
+
cleanup_connection!(close_nc: false)
|
|
462
|
+
Logging.error(
|
|
463
|
+
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
464
|
+
tag: 'JetstreamBridge::Connection'
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# Invalidate health check cache to force re-check
|
|
468
|
+
@cached_health_status = false
|
|
469
|
+
@last_health_check = Time.now.to_i
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Expose for class-level helpers (not part of public API)
|
|
473
|
+
attr_reader :nc
|
|
474
|
+
|
|
475
|
+
def jetstream
|
|
476
|
+
@jts
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Mask credentials in NATS URLs:
|
|
480
|
+
# - "nats://user:pass@host:4222" -> "nats://user:***@host:4222"
|
|
481
|
+
# - "nats://token@host:4222" -> "nats://***@host:4222"
|
|
482
|
+
def sanitize_urls(urls)
|
|
483
|
+
urls.map { |u| Logging.sanitize_url(u) }
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def cleanup_connection!(close_nc: true)
|
|
487
|
+
begin
|
|
488
|
+
# Avoid touching RSpec doubles used in unit tests
|
|
489
|
+
is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
|
|
490
|
+
@nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
|
|
491
|
+
rescue StandardError
|
|
492
|
+
# ignore cleanup errors
|
|
493
|
+
end
|
|
494
|
+
@nc = nil
|
|
495
|
+
@jts = nil
|
|
496
|
+
@cached_health_status = nil
|
|
497
|
+
@last_health_check = nil
|
|
498
|
+
@connected_at = nil
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def config_auto_provision
|
|
502
|
+
cfg = JetstreamBridge.config
|
|
503
|
+
cfg.respond_to?(:auto_provision) ? cfg.auto_provision : true
|
|
504
|
+
rescue StandardError
|
|
505
|
+
true
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'connection'
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
|
|
6
|
+
module JetstreamBridge
|
|
7
|
+
module Core
|
|
8
|
+
# Factory for creating and managing NATS connections
|
|
9
|
+
class ConnectionFactory
|
|
10
|
+
# Connection options builder
|
|
11
|
+
class ConnectionOptions
|
|
12
|
+
DEFAULT_OPTS = JetstreamBridge::Connection::DEFAULT_CONN_OPTS
|
|
13
|
+
|
|
14
|
+
attr_accessor :servers, :reconnect, :reconnect_time_wait,
|
|
15
|
+
:max_reconnect_attempts, :connect_timeout,
|
|
16
|
+
:name, :user, :pass, :token
|
|
17
|
+
attr_reader :additional_opts
|
|
18
|
+
|
|
19
|
+
def initialize(servers: nil, **opts)
|
|
20
|
+
@servers = normalize_servers(servers) if servers
|
|
21
|
+
@additional_opts = {}
|
|
22
|
+
|
|
23
|
+
DEFAULT_OPTS.merge(opts).each do |key, value|
|
|
24
|
+
if respond_to?(:"#{key}=")
|
|
25
|
+
send(:"#{key}=", value)
|
|
26
|
+
else
|
|
27
|
+
@additional_opts[key] = value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build(opts = {})
|
|
33
|
+
new(**opts)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
base = {
|
|
38
|
+
reconnect: @reconnect,
|
|
39
|
+
reconnect_time_wait: @reconnect_time_wait,
|
|
40
|
+
max_reconnect_attempts: @max_reconnect_attempts,
|
|
41
|
+
connect_timeout: @connect_timeout
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
base[:servers] = @servers if @servers
|
|
45
|
+
base[:name] = @name if @name
|
|
46
|
+
base[:user] = @user if @user
|
|
47
|
+
base[:pass] = @pass if @pass
|
|
48
|
+
base[:token] = @token if @token
|
|
49
|
+
|
|
50
|
+
base.merge(@additional_opts)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def normalize_servers(servers)
|
|
56
|
+
Array(servers)
|
|
57
|
+
.flat_map { |s| s.to_s.split(',') }
|
|
58
|
+
.map(&:strip)
|
|
59
|
+
.reject(&:empty?)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
# Create connection options from config
|
|
65
|
+
def build_options(config = JetstreamBridge.config)
|
|
66
|
+
servers = config.nats_urls
|
|
67
|
+
raise ConnectionNotEstablishedError, 'No NATS URLs configured' if servers.to_s.strip.empty?
|
|
68
|
+
|
|
69
|
+
ConnectionOptions.new(
|
|
70
|
+
servers: servers,
|
|
71
|
+
name: config.app_name
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Create a new NATS client
|
|
76
|
+
def create_client(options = nil)
|
|
77
|
+
opts = options || build_options
|
|
78
|
+
client = NATS::IO::Client.new
|
|
79
|
+
client.connect(opts.to_h)
|
|
80
|
+
client
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Create JetStream context with health monitoring
|
|
84
|
+
def create_jetstream(client)
|
|
85
|
+
jts = client.jetstream
|
|
86
|
+
|
|
87
|
+
# Ensure JetStream responds to #nc
|
|
88
|
+
jts.define_singleton_method(:nc) { client } unless jts.respond_to?(:nc)
|
|
89
|
+
|
|
90
|
+
jts
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -32,11 +32,7 @@ module JetstreamBridge
|
|
|
32
32
|
{
|
|
33
33
|
app_name: cfg.app_name,
|
|
34
34
|
destination_app: cfg.destination_app,
|
|
35
|
-
stream_name:
|
|
36
|
-
cfg.stream_name
|
|
37
|
-
rescue StandardError
|
|
38
|
-
'ERROR'
|
|
39
|
-
end,
|
|
35
|
+
stream_name: cfg.stream_name,
|
|
40
36
|
source_subject: begin
|
|
41
37
|
cfg.source_subject
|
|
42
38
|
rescue StandardError
|
|
@@ -61,9 +57,7 @@ module JetstreamBridge
|
|
|
61
57
|
use_inbox: cfg.use_inbox,
|
|
62
58
|
use_dlq: cfg.use_dlq,
|
|
63
59
|
outbox_model: cfg.outbox_model,
|
|
64
|
-
inbox_model: cfg.inbox_model
|
|
65
|
-
inbox_prefix: cfg.inbox_prefix,
|
|
66
|
-
disable_js_api: cfg.disable_js_api
|
|
60
|
+
inbox_model: cfg.inbox_model
|
|
67
61
|
}
|
|
68
62
|
end
|
|
69
63
|
|
|
@@ -81,7 +75,6 @@ module JetstreamBridge
|
|
|
81
75
|
|
|
82
76
|
def stream_debug
|
|
83
77
|
return { error: 'Not connected' } unless Connection.instance.connected?
|
|
84
|
-
return { error: 'JS API disabled' } if JetstreamBridge.config.disable_js_api
|
|
85
78
|
|
|
86
79
|
jts = Connection.jetstream
|
|
87
80
|
cfg = JetstreamBridge.config
|