jetstream_bridge 4.4.1 → 4.5.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -337
  3. data/README.md +1 -5
  4. data/docs/GETTING_STARTED.md +11 -7
  5. data/docs/PRODUCTION.md +51 -11
  6. data/docs/TESTING.md +24 -35
  7. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +1 -1
  8. data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +1 -1
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +0 -4
  10. data/lib/generators/jetstream_bridge/install/install_generator.rb +5 -5
  11. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +2 -2
  12. data/lib/jetstream_bridge/consumer/consumer.rb +34 -96
  13. data/lib/jetstream_bridge/consumer/health_monitor.rb +107 -0
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
  15. data/lib/jetstream_bridge/consumer/subscription_manager.rb +51 -34
  16. data/lib/jetstream_bridge/core/config.rb +153 -46
  17. data/lib/jetstream_bridge/core/connection_manager.rb +513 -0
  18. data/lib/jetstream_bridge/core/debug_helper.rb +9 -3
  19. data/lib/jetstream_bridge/core/health_checker.rb +184 -0
  20. data/lib/jetstream_bridge/core.rb +0 -2
  21. data/lib/jetstream_bridge/facade.rb +212 -0
  22. data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +110 -0
  23. data/lib/jetstream_bridge/publisher/publisher.rb +87 -117
  24. data/lib/jetstream_bridge/rails/integration.rb +8 -5
  25. data/lib/jetstream_bridge/rails/railtie.rb +4 -3
  26. data/lib/jetstream_bridge/tasks/install.rake +0 -1
  27. data/lib/jetstream_bridge/topology/topology.rb +6 -1
  28. data/lib/jetstream_bridge/version.rb +1 -1
  29. data/lib/jetstream_bridge.rb +206 -297
  30. metadata +7 -5
  31. data/lib/jetstream_bridge/core/bridge_helpers.rb +0 -109
  32. data/lib/jetstream_bridge/core/connection.rb +0 -464
  33. data/lib/jetstream_bridge/core/connection_factory.rb +0 -100
@@ -0,0 +1,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nats/io/client'
4
+ require 'uri'
5
+ require_relative 'logging'
6
+ require_relative 'config'
7
+ require_relative '../topology/topology'
8
+
9
+ module JetstreamBridge
10
+ # Manages NATS connection lifecycle
11
+ #
12
+ # Responsible for:
13
+ # - Establishing and closing connections
14
+ # - Validating connection URLs
15
+ # - Health checking
16
+ # - Reconnection handling
17
+ # - JetStream context management
18
+ #
19
+ # NOT a singleton - instances are created and injected
20
+ class ConnectionManager
21
+ # Connection states for observability
22
+ module State
23
+ DISCONNECTED = :disconnected
24
+ CONNECTING = :connecting
25
+ CONNECTED = :connected
26
+ RECONNECTING = :reconnecting
27
+ FAILED = :failed
28
+ end
29
+
30
+ # Valid NATS URL schemes
31
+ VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
32
+
33
+ DEFAULT_CONN_OPTS = {
34
+ reconnect: true,
35
+ reconnect_time_wait: 2,
36
+ max_reconnect_attempts: 10,
37
+ connect_timeout: 5
38
+ }.freeze
39
+
40
+ # Health check cache TTL in seconds
41
+ HEALTH_CHECK_CACHE_TTL = 30
42
+
43
+ attr_reader :config, :connected_at, :last_reconnect_error, :last_reconnect_error_at, :state
44
+
45
+ # @param config [Config] Configuration instance
46
+ def initialize(config)
47
+ @config = config
48
+ @mutex = Mutex.new
49
+ @state = State::DISCONNECTED
50
+ @nc = nil
51
+ @jts = nil
52
+ @connected_at = nil
53
+ @cached_health_status = nil
54
+ @last_health_check = nil
55
+ @reconnecting = false
56
+ end
57
+
58
+ # Establish connection to NATS and ensure topology
59
+ #
60
+ # Idempotent - safe to call multiple times
61
+ #
62
+ # @return [NATS::JetStream::JS] JetStream context
63
+ # @raise [ConnectionError] If connection fails
64
+ def connect!
65
+ @mutex.synchronize do
66
+ return @jts if connected_without_lock?
67
+
68
+ servers = validate_and_parse_servers!(@config.nats_urls)
69
+ @state = State::CONNECTING
70
+
71
+ establish_connection_with_retry(servers)
72
+
73
+ Logging.info(
74
+ "Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
75
+ "#{sanitize_urls(servers).join(', ')}",
76
+ tag: 'JetstreamBridge::ConnectionManager'
77
+ )
78
+
79
+ ensure_topology_if_enabled
80
+
81
+ @connected_at = Time.now.utc
82
+ @state = State::CONNECTED
83
+ @jts
84
+ end
85
+ rescue StandardError
86
+ @state = State::FAILED
87
+ cleanup_connection!
88
+ raise
89
+ end
90
+
91
+ # Close the NATS connection
92
+ #
93
+ # @return [void]
94
+ def disconnect!
95
+ @mutex.synchronize do
96
+ cleanup_connection!(close_nc: true)
97
+ @state = State::DISCONNECTED
98
+ Logging.info('Disconnected from NATS', tag: 'JetstreamBridge::ConnectionManager')
99
+ end
100
+ end
101
+
102
+ # Reconnect to NATS (disconnect + connect)
103
+ #
104
+ # @return [void]
105
+ def reconnect!
106
+ Logging.info('Reconnecting to NATS...', tag: 'JetstreamBridge::ConnectionManager')
107
+ disconnect!
108
+ connect!
109
+ end
110
+
111
+ # Get JetStream context
112
+ #
113
+ # @return [NATS::JetStream::JS, nil] JetStream context if connected
114
+ def jetstream
115
+ @jts
116
+ end
117
+
118
+ # Get raw NATS client
119
+ #
120
+ # @return [NATS::IO::Client, nil] NATS client if connected
121
+ def nats_client
122
+ @nc
123
+ end
124
+
125
+ # Check if connected and healthy
126
+ #
127
+ # @param skip_cache [Boolean] Force fresh health check
128
+ # @return [Boolean] true if connected and healthy
129
+ def connected?(skip_cache: false)
130
+ return false unless @nc&.connected?
131
+ return false unless @jts
132
+
133
+ # Use cached result if available and fresh
134
+ now = Time.now.to_i
135
+ if !skip_cache && @last_health_check && (now - @last_health_check) < HEALTH_CHECK_CACHE_TTL
136
+ return @cached_health_status
137
+ end
138
+
139
+ # Thread-safe cache update
140
+ @mutex.synchronize do
141
+ # Double-check after acquiring lock
142
+ now = Time.now.to_i
143
+ if !skip_cache && @last_health_check && (now - @last_health_check) < HEALTH_CHECK_CACHE_TTL
144
+ return @cached_health_status
145
+ end
146
+
147
+ # Perform actual health check
148
+ @cached_health_status = jetstream_healthy?
149
+ @last_health_check = now
150
+ @cached_health_status
151
+ end
152
+ end
153
+
154
+ # Get detailed health status
155
+ #
156
+ # @param skip_cache [Boolean] Force fresh check
157
+ # @return [Hash] Health status details
158
+ def health_check(skip_cache: false)
159
+ {
160
+ connected: connected?(skip_cache: skip_cache),
161
+ state: @state,
162
+ connected_at: @connected_at&.iso8601,
163
+ last_error: @last_reconnect_error&.message,
164
+ last_error_at: @last_reconnect_error_at&.iso8601
165
+ }
166
+ end
167
+
168
+ private
169
+
170
+ def connected_without_lock?
171
+ @jts && @nc&.connected?
172
+ end
173
+
174
+ def jetstream_healthy?
175
+ return true if @config.disable_js_api
176
+
177
+ # Verify JetStream responds to simple API call
178
+ @jts.account_info
179
+ true
180
+ rescue StandardError => e
181
+ Logging.warn(
182
+ "JetStream health check failed: #{e.class} #{e.message}",
183
+ tag: 'JetstreamBridge::ConnectionManager'
184
+ )
185
+ false
186
+ end
187
+
188
+ def establish_connection_with_retry(servers)
189
+ attempts = 0
190
+ max_attempts = @config.connect_retry_attempts
191
+ retry_delay = @config.connect_retry_delay
192
+
193
+ begin
194
+ attempts += 1
195
+ establish_connection(servers)
196
+ rescue ConnectionError => e
197
+ if attempts < max_attempts
198
+ delay = retry_delay * attempts
199
+ Logging.warn(
200
+ "Connection attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
201
+ "Retrying in #{delay}s...",
202
+ tag: 'JetstreamBridge::ConnectionManager'
203
+ )
204
+ sleep(delay)
205
+ retry
206
+ else
207
+ Logging.error(
208
+ "Failed to establish connection after #{attempts} attempts",
209
+ tag: 'JetstreamBridge::ConnectionManager'
210
+ )
211
+ raise
212
+ end
213
+ end
214
+ end
215
+
216
+ def establish_connection(servers)
217
+ @nc = create_nats_client
218
+
219
+ setup_callbacks
220
+
221
+ connect_opts = { servers: servers }.merge(DEFAULT_CONN_OPTS)
222
+ inbox_prefix = @config.inbox_prefix.to_s.strip
223
+ connect_opts[:inbox_prefix] = inbox_prefix unless inbox_prefix.empty?
224
+
225
+ @nc.connect(connect_opts) unless skip_connect?
226
+
227
+ verify_connection!
228
+
229
+ # Create JetStream context
230
+ @jts = @nc.jetstream
231
+
232
+ verify_jetstream!
233
+
234
+ # Ensure JetStream responds to #nc
235
+ add_nc_accessor unless @jts.respond_to?(:nc)
236
+ end
237
+
238
+ def create_nats_client
239
+ # Use mock NATS client if explicitly enabled for testing
240
+ if test_mode?
241
+ JetstreamBridge.instance_variable_get(:@mock_nats_client)
242
+ else
243
+ NATS::IO::Client.new
244
+ end
245
+ end
246
+
247
+ def test_mode?
248
+ defined?(JetstreamBridge::TestHelpers) &&
249
+ JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
250
+ JetstreamBridge::TestHelpers.test_mode? &&
251
+ JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
252
+ end
253
+
254
+ def skip_connect?
255
+ @nc.connected? && test_mode?
256
+ end
257
+
258
+ def setup_callbacks
259
+ @nc.on_reconnect do
260
+ @reconnecting = true
261
+ Logging.info(
262
+ 'NATS reconnected, refreshing JetStream context',
263
+ tag: 'JetstreamBridge::ConnectionManager'
264
+ )
265
+ refresh_jetstream_context
266
+ @reconnecting = false
267
+ end
268
+
269
+ @nc.on_disconnect do |reason|
270
+ @state = State::DISCONNECTED
271
+ Logging.warn(
272
+ "NATS disconnected: #{reason}",
273
+ tag: 'JetstreamBridge::ConnectionManager'
274
+ )
275
+ end
276
+
277
+ @nc.on_error do |err|
278
+ Logging.error(
279
+ "NATS error: #{err}",
280
+ tag: 'JetstreamBridge::ConnectionManager'
281
+ )
282
+ end
283
+ end
284
+
285
+ def add_nc_accessor
286
+ nc_ref = @nc
287
+ @jts.define_singleton_method(:nc) { nc_ref }
288
+ end
289
+
290
+ def verify_connection!
291
+ Logging.debug(
292
+ 'Verifying NATS connection...',
293
+ tag: 'JetstreamBridge::ConnectionManager'
294
+ )
295
+
296
+ unless @nc.connected?
297
+ Logging.error(
298
+ 'NATS connection verification failed - client not connected',
299
+ tag: 'JetstreamBridge::ConnectionManager'
300
+ )
301
+ raise ConnectionError, 'Failed to establish connection to NATS server(s)'
302
+ end
303
+
304
+ Logging.info(
305
+ 'NATS connection verified successfully',
306
+ tag: 'JetstreamBridge::ConnectionManager'
307
+ )
308
+ end
309
+
310
+ def verify_jetstream!
311
+ return true if @config.disable_js_api
312
+
313
+ Logging.debug(
314
+ 'Verifying JetStream availability...',
315
+ tag: 'JetstreamBridge::ConnectionManager'
316
+ )
317
+
318
+ account_info = @jts.account_info
319
+
320
+ # Handle both object-style and hash-style access for compatibility
321
+ streams = account_info.respond_to?(:streams) ? account_info.streams : account_info[:streams]
322
+ consumers = account_info.respond_to?(:consumers) ? account_info.consumers : account_info[:consumers]
323
+ memory = account_info.respond_to?(:memory) ? account_info.memory : account_info[:memory]
324
+ storage = account_info.respond_to?(:storage) ? account_info.storage : account_info[:storage]
325
+
326
+ Logging.info(
327
+ "JetStream verified - Streams: #{streams}, " \
328
+ "Consumers: #{consumers}, " \
329
+ "Memory: #{format_bytes(memory)}, " \
330
+ "Storage: #{format_bytes(storage)}",
331
+ tag: 'JetstreamBridge::ConnectionManager'
332
+ )
333
+ rescue NATS::IO::NoRespondersError
334
+ Logging.error(
335
+ 'JetStream not available - no responders (JetStream not enabled)',
336
+ tag: 'JetstreamBridge::ConnectionManager'
337
+ )
338
+ raise ConnectionError, 'JetStream not enabled on NATS server. Please enable JetStream with -js flag'
339
+ rescue StandardError => e
340
+ Logging.error(
341
+ "JetStream verification failed: #{e.class} - #{e.message}",
342
+ tag: 'JetstreamBridge::ConnectionManager'
343
+ )
344
+ raise ConnectionError, "JetStream verification failed: #{e.message}"
345
+ end
346
+
347
+ def ensure_topology_if_enabled
348
+ return if @config.disable_js_api
349
+
350
+ Topology.ensure!(@jts, force: true)
351
+ rescue StandardError => e
352
+ Logging.warn(
353
+ "Topology ensure skipped: #{e.class} #{e.message}",
354
+ tag: 'JetstreamBridge::ConnectionManager'
355
+ )
356
+ end
357
+
358
+ def refresh_jetstream_context
359
+ @jts = @nc.jetstream
360
+ add_nc_accessor unless @jts.respond_to?(:nc)
361
+ ensure_topology_if_enabled
362
+
363
+ # Invalidate health check cache on successful reconnect
364
+ @cached_health_status = nil
365
+ @last_health_check = nil
366
+
367
+ # Clear error state on successful reconnect
368
+ @last_reconnect_error = nil
369
+ @last_reconnect_error_at = nil
370
+ @state = State::CONNECTED
371
+
372
+ Logging.info(
373
+ 'JetStream context refreshed successfully after reconnect',
374
+ tag: 'JetstreamBridge::ConnectionManager'
375
+ )
376
+ rescue StandardError => e
377
+ # Store error state for diagnostics
378
+ @last_reconnect_error = e
379
+ @last_reconnect_error_at = Time.now
380
+ @state = State::FAILED
381
+ cleanup_connection!(close_nc: false)
382
+ Logging.error(
383
+ "Failed to refresh JetStream context: #{e.class} #{e.message}",
384
+ tag: 'JetstreamBridge::ConnectionManager'
385
+ )
386
+
387
+ # Invalidate health check cache to force re-check
388
+ @cached_health_status = false
389
+ @last_health_check = Time.now.to_i
390
+ end
391
+
392
+ def cleanup_connection!(close_nc: true)
393
+ begin
394
+ # Avoid touching RSpec doubles used in unit tests
395
+ is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
396
+ @nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
397
+ rescue StandardError
398
+ # ignore cleanup errors
399
+ end
400
+ @nc = nil
401
+ @jts = nil
402
+ @cached_health_status = nil
403
+ @last_health_check = nil
404
+ @connected_at = nil
405
+ end
406
+
407
+ def format_bytes(bytes)
408
+ return 'N/A' if bytes.nil? || bytes.zero?
409
+
410
+ units = %w[B KB MB GB TB]
411
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
412
+ exp = [exp, units.length - 1].min
413
+ "#{(bytes / (1024.0**exp)).round(2)} #{units[exp]}"
414
+ end
415
+
416
+ def sanitize_urls(urls)
417
+ urls.map { |u| Logging.sanitize_url(u) }
418
+ end
419
+
420
+ # Validate and parse NATS server URLs
421
+ #
422
+ # @param urls [String] Comma-separated NATS URLs
423
+ # @return [Array<String>] Validated server URLs
424
+ # @raise [ConnectionError] If validation fails
425
+ def validate_and_parse_servers!(urls)
426
+ servers = parse_urls(urls)
427
+ validate_not_empty!(servers)
428
+
429
+ servers.each { |url| validate_url!(url) }
430
+
431
+ Logging.info(
432
+ 'All NATS URLs validated successfully',
433
+ tag: 'JetstreamBridge::ConnectionManager'
434
+ )
435
+
436
+ servers
437
+ end
438
+
439
+ def parse_urls(urls)
440
+ urls.to_s
441
+ .split(',')
442
+ .map(&:strip)
443
+ .reject(&:empty?)
444
+ end
445
+
446
+ def validate_not_empty!(servers)
447
+ return unless servers.empty?
448
+
449
+ raise ConnectionError, 'No NATS URLs configured'
450
+ end
451
+
452
+ def validate_url!(url)
453
+ validate_url_format!(url)
454
+
455
+ uri = URI.parse(url)
456
+ validate_url_scheme!(uri, url)
457
+ validate_url_host!(uri, url)
458
+ validate_url_port!(uri, url)
459
+
460
+ Logging.debug(
461
+ "URL validated: #{Logging.sanitize_url(url)}",
462
+ tag: 'JetstreamBridge::ConnectionManager'
463
+ )
464
+ rescue URI::InvalidURIError => e
465
+ Logging.error(
466
+ "Malformed URL: #{url} (#{e.message})",
467
+ tag: 'JetstreamBridge::ConnectionManager'
468
+ )
469
+ raise ConnectionError, "Invalid NATS URL format: #{url} (#{e.message})"
470
+ end
471
+
472
+ def validate_url_format!(url)
473
+ return if url.include?('://')
474
+
475
+ Logging.error(
476
+ "Invalid URL format (missing scheme): #{url}",
477
+ tag: 'JetstreamBridge::ConnectionManager'
478
+ )
479
+ raise ConnectionError, "Invalid NATS URL format: #{url}. Expected format: nats://host:port"
480
+ end
481
+
482
+ def validate_url_scheme!(uri, url)
483
+ scheme = uri.scheme&.downcase
484
+ return if VALID_NATS_SCHEMES.include?(scheme)
485
+
486
+ Logging.error(
487
+ "Invalid URL scheme '#{uri.scheme}': #{Logging.sanitize_url(url)}",
488
+ tag: 'JetstreamBridge::ConnectionManager'
489
+ )
490
+ raise ConnectionError, "Invalid NATS URL scheme '#{uri.scheme}' in: #{url}. Expected 'nats' or 'nats+tls'"
491
+ end
492
+
493
+ def validate_url_host!(uri, url)
494
+ return unless uri.host.nil? || uri.host.empty?
495
+
496
+ Logging.error(
497
+ "Missing host in URL: #{Logging.sanitize_url(url)}",
498
+ tag: 'JetstreamBridge::ConnectionManager'
499
+ )
500
+ raise ConnectionError, "Invalid NATS URL - missing host: #{url}"
501
+ end
502
+
503
+ def validate_url_port!(uri, url)
504
+ return unless uri.port && (uri.port < 1 || uri.port > 65_535)
505
+
506
+ Logging.error(
507
+ "Invalid port #{uri.port} in URL: #{Logging.sanitize_url(url)}",
508
+ tag: 'JetstreamBridge::ConnectionManager'
509
+ )
510
+ raise ConnectionError, "Invalid NATS URL - port must be 1-65535: #{url}"
511
+ end
512
+ end
513
+ end
@@ -30,10 +30,13 @@ module JetstreamBridge
30
30
  def config_debug
31
31
  cfg = JetstreamBridge.config
32
32
  {
33
- env: cfg.env,
34
33
  app_name: cfg.app_name,
35
34
  destination_app: cfg.destination_app,
36
- stream_name: cfg.stream_name,
35
+ stream_name: begin
36
+ cfg.stream_name
37
+ rescue StandardError
38
+ 'ERROR'
39
+ end,
37
40
  source_subject: begin
38
41
  cfg.source_subject
39
42
  rescue StandardError
@@ -58,7 +61,9 @@ module JetstreamBridge
58
61
  use_inbox: cfg.use_inbox,
59
62
  use_dlq: cfg.use_dlq,
60
63
  outbox_model: cfg.outbox_model,
61
- inbox_model: cfg.inbox_model
64
+ inbox_model: cfg.inbox_model,
65
+ inbox_prefix: cfg.inbox_prefix,
66
+ disable_js_api: cfg.disable_js_api
62
67
  }
63
68
  end
64
69
 
@@ -76,6 +81,7 @@ module JetstreamBridge
76
81
 
77
82
  def stream_debug
78
83
  return { error: 'Not connected' } unless Connection.instance.connected?
84
+ return { error: 'JS API disabled' } if JetstreamBridge.config.disable_js_api
79
85
 
80
86
  jts = Connection.jetstream
81
87
  cfg = JetstreamBridge.config