jetstream_bridge 2.10.0 → 3.0.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +164 -0
  3. data/LICENSE +21 -0
  4. data/README.md +397 -0
  5. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
  6. data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
  7. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +58 -13
  8. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +1 -1
  9. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +1 -1
  10. data/lib/jetstream_bridge/consumer/consumer.rb +42 -1
  11. data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
  12. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +3 -1
  13. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -31
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +65 -31
  15. data/lib/jetstream_bridge/core/config.rb +35 -0
  16. data/lib/jetstream_bridge/core/connection.rb +78 -3
  17. data/lib/jetstream_bridge/core/connection_factory.rb +100 -0
  18. data/lib/jetstream_bridge/core/debug_helper.rb +119 -0
  19. data/lib/jetstream_bridge/core/duration.rb +8 -1
  20. data/lib/jetstream_bridge/core/retry_strategy.rb +136 -0
  21. data/lib/jetstream_bridge/errors.rb +39 -0
  22. data/lib/jetstream_bridge/models/event_envelope.rb +136 -0
  23. data/lib/jetstream_bridge/models/subject.rb +94 -0
  24. data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
  25. data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
  26. data/lib/jetstream_bridge/railtie.rb +33 -1
  27. data/lib/jetstream_bridge/tasks/install.rake +99 -0
  28. data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
  29. data/lib/jetstream_bridge/topology/stream.rb +15 -5
  30. data/lib/jetstream_bridge/version.rb +1 -1
  31. data/lib/jetstream_bridge.rb +65 -0
  32. metadata +55 -11
@@ -1,27 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Jetstream Bridge configuration
3
+ # JetstreamBridge configuration
4
+ # See https://github.com/your-org/jetstream_bridge for full documentation
5
+ #
6
+ # This initializer configures the JetStream Bridge gem for Rails applications.
7
+ # The gem provides reliable, production-ready message passing between services
8
+ # using NATS JetStream with support for outbox/inbox patterns, DLQ, and more.
4
9
  JetstreamBridge.configure do |config|
5
- # NATS Connection
6
- config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
7
- config.env = ENV.fetch('NATS_ENV', Rails.env)
8
- config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
9
- config.destination_app = ENV.fetch('DESTINATION_APP', nil) # required for cross-app data sync
10
+ # ============================================================================
11
+ # NATS Connection Settings
12
+ # ============================================================================
13
+ # NATS server URLs (comma-separated for cluster)
14
+ config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
10
15
 
11
- # Consumer Tuning
16
+ # Environment identifier (e.g., 'development', 'production')
17
+ # Used in stream names and subject routing
18
+ config.env = ENV.fetch('NATS_ENV', Rails.env)
19
+
20
+ # Application name (used in subject routing)
21
+ config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
22
+
23
+ # Destination app for cross-app sync (REQUIRED for publishing/consuming)
24
+ config.destination_app = ENV.fetch('DESTINATION_APP', nil)
25
+
26
+ # ============================================================================
27
+ # Consumer Settings
28
+ # ============================================================================
29
+ # Maximum delivery attempts before moving message to DLQ
12
30
  config.max_deliver = 5
13
- config.ack_wait = '30s'
14
- config.backoff = %w[1s 5s 15s 30s 60s]
15
31
 
32
+ # How long to wait for message acknowledgment
33
+ config.ack_wait = '30s'
34
+
35
+ # Exponential backoff delays between retry attempts
36
+ config.backoff = %w[1s 5s 15s 30s 60s]
37
+
38
+ # ============================================================================
16
39
  # Reliability Features
40
+ # ============================================================================
41
+ # Enable transactional outbox pattern for guaranteed message delivery
17
42
  config.use_outbox = false
18
- config.use_inbox = false
19
- config.use_dlq = true
20
43
 
21
- # Models (override if you keep custom AR classes)
44
+ # Enable inbox pattern for exactly-once processing (idempotency)
45
+ config.use_inbox = false
46
+
47
+ # Enable Dead Letter Queue for failed messages
48
+ config.use_dlq = true
49
+
50
+ # ============================================================================
51
+ # Active Record Models
52
+ # ============================================================================
53
+ # Override these if you've created custom model classes
22
54
  config.outbox_model = 'JetstreamBridge::OutboxEvent'
23
- config.inbox_model = 'JetstreamBridge::InboxEvent'
55
+ config.inbox_model = 'JetstreamBridge::InboxEvent'
24
56
 
57
+ # ============================================================================
25
58
  # Logging
59
+ # ============================================================================
60
+ # Custom logger (defaults to Rails.logger if not set)
26
61
  # config.logger = Rails.logger
27
62
  end
63
+
64
+ # Validate configuration (optional but recommended for development)
65
+ # Uncomment to enable validation on Rails boot
66
+ # begin
67
+ # JetstreamBridge.config.validate!
68
+ # Rails.logger.info "[JetStream Bridge] Configuration validated successfully"
69
+ # rescue JetstreamBridge::ConfigurationError => e
70
+ # Rails.logger.error "[JetStream Bridge] Configuration error: #{e.message}"
71
+ # raise if Rails.env.production?
72
+ # end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateJetstreamInboxEvents < ActiveRecord::Migration[7.0]
3
+ class CreateJetstreamInboxEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  def change
5
5
  create_table :jetstream_inbox_events do |t|
6
6
  t.string :event_id # preferred dedupe key
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateJetstreamOutboxEvents < ActiveRecord::Migration[7.0]
3
+ class CreateJetstreamOutboxEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  def change
5
5
  create_table :jetstream_outbox_events do |t|
6
6
  t.string :event_id, null: false
@@ -27,7 +27,8 @@ module JetstreamBridge
27
27
  @durable = durable_name || JetstreamBridge.config.durable_name
28
28
  @idle_backoff = IDLE_SLEEP_SECS
29
29
  @running = true
30
- @jts = Connection.connect!
30
+ @shutdown_requested = false
31
+ @jts = Connection.connect!
31
32
 
32
33
  ensure_destination!
33
34
 
@@ -36,6 +37,7 @@ module JetstreamBridge
36
37
  @inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
37
38
 
38
39
  ensure_subscription!
40
+ setup_signal_handlers
39
41
  end
40
42
 
41
43
  def run!
@@ -47,11 +49,17 @@ module JetstreamBridge
47
49
  processed = process_batch
48
50
  idle_sleep(processed)
49
51
  end
52
+
53
+ # Drain in-flight messages before exiting
54
+ drain_inflight_messages if @shutdown_requested
55
+ Logging.info("Consumer #{@durable} stopped gracefully", tag: 'JetstreamBridge::Consumer')
50
56
  end
51
57
 
52
58
  # Allow external callers to stop a long-running loop gracefully.
53
59
  def stop!
60
+ @shutdown_requested = true
54
61
  @running = false
62
+ Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
55
63
  end
56
64
 
57
65
  private
@@ -138,5 +146,38 @@ module JetstreamBridge
138
146
  @idle_backoff = IDLE_SLEEP_SECS
139
147
  end
140
148
  end
149
+
150
+ def setup_signal_handlers
151
+ %w[INT TERM].each do |sig|
152
+ Signal.trap(sig) do
153
+ Logging.info("Received #{sig}, stopping consumer...", tag: 'JetstreamBridge::Consumer')
154
+ stop!
155
+ end
156
+ end
157
+ rescue ArgumentError => e
158
+ # Signal handlers may not be available in all environments (e.g., threads)
159
+ Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
160
+ end
161
+
162
+ def drain_inflight_messages
163
+ return unless @psub
164
+
165
+ Logging.info('Draining in-flight messages...', tag: 'JetstreamBridge::Consumer')
166
+ # Process any pending messages with a short timeout
167
+ 5.times do
168
+ msgs = @psub.fetch(@batch_size, timeout: 1)
169
+ break if msgs.nil? || msgs.empty?
170
+
171
+ msgs.each { |m| process_one(m) }
172
+ rescue NATS::Timeout, NATS::IO::Timeout
173
+ break
174
+ rescue StandardError => e
175
+ Logging.warn("Error draining messages: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
176
+ break
177
+ end
178
+ Logging.info('Drain complete', tag: 'JetstreamBridge::Consumer')
179
+ rescue StandardError => e
180
+ Logging.error("Drain failed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
181
+ end
141
182
  end
142
183
  end
@@ -11,17 +11,20 @@ module JetstreamBridge
11
11
  end
12
12
 
13
13
  # Sends original payload to DLQ with explanatory headers/context
14
+ # @return [Boolean] true if published successfully, false otherwise
14
15
  def publish(msg, ctx, reason:, error_class:, error_message:)
15
- return unless JetstreamBridge.config.use_dlq
16
+ return true unless JetstreamBridge.config.use_dlq
16
17
 
17
18
  envelope = build_envelope(ctx, reason, error_class, error_message)
18
19
  headers = build_headers(msg.header, reason, ctx.deliveries, envelope)
19
20
  @jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: headers)
21
+ true
20
22
  rescue StandardError => e
21
23
  Logging.error(
22
24
  "DLQ publish failed event_id=#{ctx.event_id}: #{e.class} #{e.message}",
23
25
  tag: 'JetstreamBridge::Consumer'
24
26
  )
27
+ false
25
28
  end
26
29
 
27
30
  private
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'oj'
4
+ require 'securerandom'
4
5
 
5
6
  module JetstreamBridge
6
7
  # Immutable value object for a single NATS message.
@@ -27,7 +28,8 @@ module JetstreamBridge
27
28
  end
28
29
 
29
30
  id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
30
- id = "seq:#{seq}" if id.empty?
31
+ id = "seq:#{seq}" if id.empty? && seq
32
+ id = SecureRandom.uuid if id.to_s.empty?
31
33
 
32
34
  new(msg, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc, consumer)
33
35
  end
@@ -25,45 +25,51 @@ module JetstreamBridge
25
25
  end
26
26
 
27
27
  def persist_pre(record, msg)
28
- attrs = {
29
- event_id: msg.event_id,
30
- subject: msg.subject,
31
- payload: ModelUtils.json_dump(msg.body_for_store),
32
- headers: ModelUtils.json_dump(msg.headers),
33
- stream: msg.stream,
34
- stream_seq: msg.seq,
35
- deliveries: msg.deliveries,
36
- status: 'processing',
37
- last_error: nil,
38
- received_at: record.respond_to?(:received_at) ? (record.received_at || msg.now) : nil,
39
- updated_at: record.respond_to?(:updated_at) ? msg.now : nil
40
- }
41
- ModelUtils.assign_known_attrs(record, attrs)
42
- record.save!
28
+ ActiveRecord::Base.transaction do
29
+ attrs = {
30
+ event_id: msg.event_id,
31
+ subject: msg.subject,
32
+ payload: ModelUtils.json_dump(msg.body_for_store),
33
+ headers: ModelUtils.json_dump(msg.headers),
34
+ stream: msg.stream,
35
+ stream_seq: msg.seq,
36
+ deliveries: msg.deliveries,
37
+ status: 'processing',
38
+ last_error: nil,
39
+ received_at: record.respond_to?(:received_at) ? (record.received_at || msg.now) : nil,
40
+ updated_at: record.respond_to?(:updated_at) ? msg.now : nil
41
+ }
42
+ ModelUtils.assign_known_attrs(record, attrs)
43
+ record.save!
44
+ end
43
45
  end
44
46
 
45
47
  def persist_post(record)
46
- now = Time.now.utc
47
- attrs = {
48
- status: 'processed',
49
- processed_at: record.respond_to?(:processed_at) ? now : nil,
50
- updated_at: record.respond_to?(:updated_at) ? now : nil
51
- }
52
- ModelUtils.assign_known_attrs(record, attrs)
53
- record.save!
48
+ ActiveRecord::Base.transaction do
49
+ now = Time.now.utc
50
+ attrs = {
51
+ status: 'processed',
52
+ processed_at: record.respond_to?(:processed_at) ? now : nil,
53
+ updated_at: record.respond_to?(:updated_at) ? now : nil
54
+ }
55
+ ModelUtils.assign_known_attrs(record, attrs)
56
+ record.save!
57
+ end
54
58
  end
55
59
 
56
60
  def persist_failure(record, error)
57
61
  return unless record
58
62
 
59
- now = Time.now.utc
60
- attrs = {
61
- status: 'failed',
62
- last_error: "#{error.class}: #{error.message}",
63
- updated_at: record.respond_to?(:updated_at) ? now : nil
64
- }
65
- ModelUtils.assign_known_attrs(record, attrs)
66
- record.save!
63
+ ActiveRecord::Base.transaction do
64
+ now = Time.now.utc
65
+ attrs = {
66
+ status: 'failed',
67
+ last_error: "#{error.class}: #{error.message}",
68
+ updated_at: record.respond_to?(:updated_at) ? now : nil
69
+ }
70
+ ModelUtils.assign_known_attrs(record, attrs)
71
+ record.save!
72
+ end
67
73
  rescue StandardError => e
68
74
  Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
69
75
  tag: 'JetstreamBridge::Consumer')
@@ -63,12 +63,13 @@ module JetstreamBridge
63
63
 
64
64
  process_event(msg, event, ctx)
65
65
  rescue StandardError => e
66
+ backtrace = e.backtrace&.first(5)&.join("\n ")
66
67
  Logging.error(
67
68
  "Processor crashed event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} " \
68
- "deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}",
69
+ "deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}\n #{backtrace}",
69
70
  tag: 'JetstreamBridge::Consumer'
70
71
  )
71
- safe_nak(msg)
72
+ safe_nak(msg, ctx, e)
72
73
  end
73
74
 
74
75
  private
@@ -77,14 +78,22 @@ module JetstreamBridge
77
78
  data = msg.data
78
79
  Oj.load(data, mode: :strict)
79
80
  rescue Oj::ParseError => e
80
- @dlq.publish(msg, ctx,
81
- reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
82
- msg.ack
83
- Logging.warn(
84
- "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
85
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
86
- tag: 'JetstreamBridge::Consumer'
87
- )
81
+ dlq_success = @dlq.publish(msg, ctx,
82
+ reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
83
+ if dlq_success
84
+ msg.ack
85
+ Logging.warn(
86
+ "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
87
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
88
+ tag: 'JetstreamBridge::Consumer'
89
+ )
90
+ else
91
+ safe_nak(msg, ctx, e)
92
+ Logging.error(
93
+ "Malformed JSON, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
94
+ tag: 'JetstreamBridge::Consumer'
95
+ )
96
+ end
88
97
  nil
89
98
  end
90
99
 
@@ -96,14 +105,22 @@ module JetstreamBridge
96
105
  tag: 'JetstreamBridge::Consumer'
97
106
  )
98
107
  rescue *UNRECOVERABLE_ERRORS => e
99
- @dlq.publish(msg, ctx,
100
- reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
101
- msg.ack
102
- Logging.warn(
103
- "DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
104
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
105
- tag: 'JetstreamBridge::Consumer'
106
- )
108
+ dlq_success = @dlq.publish(msg, ctx,
109
+ reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
110
+ if dlq_success
111
+ msg.ack
112
+ Logging.warn(
113
+ "DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
114
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
115
+ tag: 'JetstreamBridge::Consumer'
116
+ )
117
+ else
118
+ safe_nak(msg, ctx, e)
119
+ Logging.error(
120
+ "Unrecoverable error, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
121
+ tag: 'JetstreamBridge::Consumer'
122
+ )
123
+ end
107
124
  rescue StandardError => e
108
125
  ack_or_nak(msg, ctx, e)
109
126
  end
@@ -111,14 +128,28 @@ module JetstreamBridge
111
128
  def ack_or_nak(msg, ctx, error)
112
129
  max_deliver = JetstreamBridge.config.max_deliver.to_i
113
130
  if ctx.deliveries >= max_deliver
114
- @dlq.publish(msg, ctx,
115
- reason: 'max_deliver_exceeded', error_class: error.class.name, error_message: error.message)
116
- msg.ack
117
- Logging.warn(
118
- "DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
119
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
120
- tag: 'JetstreamBridge::Consumer'
121
- )
131
+ # Only ACK if DLQ publish succeeds
132
+ dlq_success = @dlq.publish(msg, ctx,
133
+ reason: 'max_deliver_exceeded',
134
+ error_class: error.class.name,
135
+ error_message: error.message)
136
+
137
+ if dlq_success
138
+ msg.ack
139
+ Logging.warn(
140
+ "DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
141
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
142
+ tag: 'JetstreamBridge::Consumer'
143
+ )
144
+ else
145
+ # NAK to retry DLQ publish
146
+ safe_nak(msg, ctx, error)
147
+ Logging.error(
148
+ "DLQ publish failed at max_deliver, NAKing event_id=#{ctx.event_id} " \
149
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
150
+ tag: 'JetstreamBridge::Consumer'
151
+ )
152
+ end
122
153
  else
123
154
  safe_nak(msg, ctx, error)
124
155
  Logging.warn(
@@ -129,11 +160,14 @@ module JetstreamBridge
129
160
  end
130
161
  end
131
162
 
132
- def safe_nak(msg, ctx = nil, _error = nil)
133
- # If your NATS client supports delayed NAKs, uncomment:
134
- # delay = @backoff.delay(ctx&.deliveries.to_i, error) if ctx
135
- # msg.nak(next_delivery_delay: delay)
136
- msg.nak
163
+ def safe_nak(msg, ctx = nil, error = nil)
164
+ # Use backoff strategy with error context if available
165
+ if ctx && error && msg.respond_to?(:nak_with_delay)
166
+ delay = @backoff.delay(ctx.deliveries.to_i, error)
167
+ msg.nak_with_delay(delay)
168
+ else
169
+ msg.nak
170
+ end
137
171
  rescue StandardError => e
138
172
  Logging.error(
139
173
  "Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../errors'
4
+
3
5
  module JetstreamBridge
4
6
  class Config
5
7
  attr_accessor :destination_app, :nats_urls, :env, :app_name,
@@ -34,20 +36,53 @@ module JetstreamBridge
34
36
  # Producer publishes to: {env}.{app}.sync.{dest}
35
37
  # Consumer subscribes to: {env}.{dest}.sync.{app}
36
38
  def source_subject
39
+ validate_subject_component!(env, 'env')
40
+ validate_subject_component!(app_name, 'app_name')
41
+ validate_subject_component!(destination_app, 'destination_app')
37
42
  "#{env}.#{app_name}.sync.#{destination_app}"
38
43
  end
39
44
 
40
45
  def destination_subject
46
+ validate_subject_component!(env, 'env')
47
+ validate_subject_component!(app_name, 'app_name')
48
+ validate_subject_component!(destination_app, 'destination_app')
41
49
  "#{env}.#{destination_app}.sync.#{app_name}"
42
50
  end
43
51
 
44
52
  # DLQ
45
53
  def dlq_subject
54
+ validate_subject_component!(env, 'env')
46
55
  "#{env}.sync.dlq"
47
56
  end
48
57
 
49
58
  def durable_name
50
59
  "#{env}-#{app_name}-workers"
51
60
  end
61
+
62
+ # Validate configuration settings
63
+ def validate!
64
+ errors = []
65
+ errors << 'destination_app is required' if destination_app.to_s.strip.empty?
66
+ errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
67
+ errors << 'env is required' if env.to_s.strip.empty?
68
+ errors << 'app_name is required' if app_name.to_s.strip.empty?
69
+ errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
70
+ errors << 'backoff must be an array' unless backoff.is_a?(Array)
71
+ errors << 'backoff must not be empty' if backoff.is_a?(Array) && backoff.empty?
72
+
73
+ raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
74
+
75
+ true
76
+ end
77
+
78
+ private
79
+
80
+ def validate_subject_component!(value, name)
81
+ str = value.to_s
82
+ if str.match?(/[.*>]/)
83
+ raise InvalidSubjectError, "#{name} cannot contain NATS wildcards (., *, >): #{value.inspect}"
84
+ end
85
+ raise MissingConfigurationError, "#{name} cannot be empty" if str.strip.empty?
86
+ end
52
87
  end
53
88
  end
@@ -9,7 +9,21 @@ require_relative 'config'
9
9
  require_relative '../topology/topology'
10
10
 
11
11
  module JetstreamBridge
12
- # Singleton connection to NATS.
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(...)
13
27
  class Connection
14
28
  include Singleton
15
29
 
@@ -23,6 +37,10 @@ module JetstreamBridge
23
37
  class << self
24
38
  # Thread-safe delegator to the singleton instance.
25
39
  # Returns a live JetStream context.
40
+ #
41
+ # Safe to call from multiple threads - uses mutex for synchronization.
42
+ #
43
+ # @return [NATS::JetStream::JS] JetStream context
26
44
  def connect!
27
45
  @__mutex ||= Mutex.new
28
46
  @__mutex.synchronize { instance.connect! }
@@ -56,13 +74,32 @@ module JetstreamBridge
56
74
  # Ensure topology (streams, subjects, overlap guard, etc.)
57
75
  Topology.ensure!(@jts)
58
76
 
77
+ @connected_at = Time.now.utc
59
78
  @jts
60
79
  end
61
80
 
81
+ # Public API for checking connection status
82
+ # @return [Boolean] true if NATS client is connected and JetStream is healthy
83
+ def connected?
84
+ @nc&.connected? && @jts && jetstream_healthy?
85
+ end
86
+
87
+ # Public API for getting connection timestamp
88
+ # @return [Time, nil] timestamp when connection was established
89
+ attr_reader :connected_at
90
+
62
91
  private
63
92
 
64
- def connected?
65
- @nc&.connected?
93
+ def jetstream_healthy?
94
+ # Verify JetStream responds to simple API call
95
+ @jts.account_info
96
+ true
97
+ rescue StandardError => e
98
+ Logging.warn(
99
+ "JetStream health check failed: #{e.class} #{e.message}",
100
+ tag: 'JetstreamBridge::Connection'
101
+ )
102
+ false
66
103
  end
67
104
 
68
105
  def nats_servers
@@ -75,6 +112,30 @@ module JetstreamBridge
75
112
 
76
113
  def establish_connection(servers)
77
114
  @nc = NATS::IO::Client.new
115
+
116
+ # Setup reconnect handler to refresh JetStream context
117
+ @nc.on_reconnect do
118
+ Logging.info(
119
+ 'NATS reconnected, refreshing JetStream context',
120
+ tag: 'JetstreamBridge::Connection'
121
+ )
122
+ refresh_jetstream_context
123
+ end
124
+
125
+ @nc.on_disconnect do |reason|
126
+ Logging.warn(
127
+ "NATS disconnected: #{reason}",
128
+ tag: 'JetstreamBridge::Connection'
129
+ )
130
+ end
131
+
132
+ @nc.on_error do |err|
133
+ Logging.error(
134
+ "NATS error: #{err}",
135
+ tag: 'JetstreamBridge::Connection'
136
+ )
137
+ end
138
+
78
139
  @nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
79
140
 
80
141
  # Create JetStream context
@@ -87,6 +148,20 @@ module JetstreamBridge
87
148
  @jts.define_singleton_method(:nc) { nc_ref }
88
149
  end
89
150
 
151
+ def refresh_jetstream_context
152
+ @jts = @nc.jetstream
153
+ nc_ref = @nc
154
+ @jts.define_singleton_method(:nc) { nc_ref } unless @jts.respond_to?(:nc)
155
+
156
+ # Re-ensure topology after reconnect
157
+ Topology.ensure!(@jts)
158
+ rescue StandardError => e
159
+ Logging.error(
160
+ "Failed to refresh JetStream context: #{e.class} #{e.message}",
161
+ tag: 'JetstreamBridge::Connection'
162
+ )
163
+ end
164
+
90
165
  # Expose for class-level helpers (not part of public API)
91
166
  attr_reader :nc
92
167