jetstream_bridge 4.1.0 → 4.3.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.
@@ -50,6 +50,7 @@ module JetstreamBridge
50
50
  # Orchestrates parse → handler → ack/nak → DLQ
51
51
  class MessageProcessor
52
52
  UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
53
+ ActionResult = Struct.new(:action, :ctx, :error, :delay, keyword_init: true)
53
54
 
54
55
  attr_reader :middleware_chain
55
56
 
@@ -61,12 +62,15 @@ module JetstreamBridge
61
62
  @middleware_chain = middleware_chain || ConsumerMiddleware::MiddlewareChain.new
62
63
  end
63
64
 
64
- def handle_message(msg)
65
- ctx = MessageContext.build(msg)
66
- event = parse_message(msg, ctx)
67
- return unless event
65
+ def handle_message(msg, auto_ack: true)
66
+ ctx = MessageContext.build(msg)
67
+ event, early_action = parse_message(msg, ctx)
68
+ return apply_action(msg, early_action) if early_action && auto_ack
69
+ return early_action if early_action
68
70
 
69
- process_event(msg, event, ctx)
71
+ result = process_event(msg, event, ctx)
72
+ apply_action(msg, result) if auto_ack
73
+ result
70
74
  rescue StandardError => e
71
75
  backtrace = e.backtrace&.first(5)&.join("\n ")
72
76
  Logging.error(
@@ -74,32 +78,35 @@ module JetstreamBridge
74
78
  "deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}\n #{backtrace}",
75
79
  tag: 'JetstreamBridge::Consumer'
76
80
  )
77
- safe_nak(msg, ctx, e)
81
+ action = ActionResult.new(action: :nak, ctx: ctx, error: e)
82
+ apply_action(msg, action) if auto_ack
83
+ action
78
84
  end
79
85
 
80
86
  private
81
87
 
82
88
  def parse_message(msg, ctx)
83
89
  data = msg.data
84
- Oj.load(data, mode: :strict)
90
+ [Oj.load(data, mode: :strict), nil]
85
91
  rescue Oj::ParseError => e
86
92
  dlq_success = @dlq.publish(msg, ctx,
87
93
  reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
88
- if dlq_success
89
- msg.ack
90
- Logging.warn(
91
- "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
92
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
93
- tag: 'JetstreamBridge::Consumer'
94
- )
95
- else
96
- safe_nak(msg, ctx, e)
97
- Logging.error(
98
- "Malformed JSON, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
99
- tag: 'JetstreamBridge::Consumer'
100
- )
101
- end
102
- nil
94
+ action = if dlq_success
95
+ Logging.warn(
96
+ "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
97
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
98
+ tag: 'JetstreamBridge::Consumer'
99
+ )
100
+ ActionResult.new(action: :ack, ctx: ctx)
101
+ else
102
+ Logging.error(
103
+ "Malformed JSON, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
104
+ tag: 'JetstreamBridge::Consumer'
105
+ )
106
+ ActionResult.new(action: :nak, ctx: ctx, error: e,
107
+ delay: backoff_delay(ctx, e))
108
+ end
109
+ [nil, action]
103
110
  end
104
111
 
105
112
  def process_event(msg, event_hash, ctx)
@@ -113,33 +120,14 @@ module JetstreamBridge
113
120
  call_handler(event, event_hash, ctx)
114
121
  end
115
122
 
116
- msg.ack
117
- Logging.info(
118
- "ACK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
119
- tag: 'JetstreamBridge::Consumer'
120
- )
123
+ ActionResult.new(action: :ack, ctx: ctx)
121
124
  rescue *UNRECOVERABLE_ERRORS => e
122
- dlq_success = @dlq.publish(msg, ctx,
123
- reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
124
- if dlq_success
125
- msg.ack
126
- Logging.warn(
127
- "DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
128
- "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
129
- tag: 'JetstreamBridge::Consumer'
130
- )
131
- else
132
- safe_nak(msg, ctx, e)
133
- Logging.error(
134
- "Unrecoverable error, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
135
- tag: 'JetstreamBridge::Consumer'
136
- )
137
- end
125
+ dlq_action(msg, ctx, e, reason: 'unrecoverable')
138
126
  rescue StandardError => e
139
- ack_or_nak(msg, ctx, e)
127
+ ack_or_nak_action(msg, ctx, e)
140
128
  end
141
129
 
142
- def ack_or_nak(msg, ctx, error)
130
+ def ack_or_nak_action(msg, ctx, error)
143
131
  max_deliver = JetstreamBridge.config.max_deliver.to_i
144
132
  if ctx.deliveries >= max_deliver
145
133
  # Only ACK if DLQ publish succeeds
@@ -149,36 +137,78 @@ module JetstreamBridge
149
137
  error_message: error.message)
150
138
 
151
139
  if dlq_success
152
- msg.ack
153
140
  Logging.warn(
154
141
  "DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
155
142
  "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
156
143
  tag: 'JetstreamBridge::Consumer'
157
144
  )
145
+ ActionResult.new(action: :ack, ctx: ctx)
158
146
  else
159
- # NAK to retry DLQ publish
160
- safe_nak(msg, ctx, error)
161
147
  Logging.error(
162
148
  "DLQ publish failed at max_deliver, NAKing event_id=#{ctx.event_id} " \
163
149
  "seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
164
150
  tag: 'JetstreamBridge::Consumer'
165
151
  )
152
+ ActionResult.new(action: :nak, ctx: ctx, error: error,
153
+ delay: backoff_delay(ctx, error))
166
154
  end
167
155
  else
168
- safe_nak(msg, ctx, error)
169
156
  Logging.warn(
170
157
  "NAK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
171
158
  "deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
172
159
  tag: 'JetstreamBridge::Consumer'
173
160
  )
161
+ ActionResult.new(action: :nak, ctx: ctx, error: error,
162
+ delay: backoff_delay(ctx, error))
174
163
  end
175
164
  end
176
165
 
177
- def safe_nak(msg, ctx = nil, error = nil)
166
+ def dlq_action(msg, ctx, error, reason:)
167
+ dlq_success = @dlq.publish(msg, ctx,
168
+ reason: reason, error_class: error.class.name, error_message: error.message)
169
+ if dlq_success
170
+ Logging.warn(
171
+ "DLQ (#{reason}) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
172
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
173
+ tag: 'JetstreamBridge::Consumer'
174
+ )
175
+ ActionResult.new(action: :ack, ctx: ctx)
176
+ else
177
+ Logging.error(
178
+ "DLQ publish failed (#{reason}), NAKing event_id=#{ctx.event_id}",
179
+ tag: 'JetstreamBridge::Consumer'
180
+ )
181
+ ActionResult.new(action: :nak, ctx: ctx, error: error,
182
+ delay: backoff_delay(ctx, error))
183
+ end
184
+ end
185
+
186
+ def apply_action(msg, action_result)
187
+ return unless action_result
188
+
189
+ case action_result.action
190
+ when :ack
191
+ msg.ack
192
+ log_ack(action_result)
193
+ when :nak
194
+ safe_nak(msg, action_result.ctx, action_result.error, delay: action_result.delay)
195
+ end
196
+ action_result
197
+ end
198
+
199
+ def log_ack(result)
200
+ ctx = result.ctx
201
+ Logging.info(
202
+ "ACK event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} deliveries=#{ctx&.deliveries}",
203
+ tag: 'JetstreamBridge::Consumer'
204
+ )
205
+ end
206
+
207
+ def safe_nak(msg, ctx = nil, error = nil, delay: nil)
178
208
  # Use backoff strategy with error context if available
179
209
  if ctx && error && msg.respond_to?(:nak_with_delay)
180
- delay = @backoff.delay(ctx.deliveries.to_i, error)
181
- msg.nak_with_delay(delay)
210
+ nak_delay = delay || @backoff.delay(ctx.deliveries.to_i, error)
211
+ msg.nak_with_delay(nak_delay)
182
212
  else
183
213
  msg.nak
184
214
  end
@@ -209,5 +239,11 @@ module JetstreamBridge
209
239
  def call_handler(event, _event_hash, _ctx)
210
240
  @handler.call(event)
211
241
  end
242
+
243
+ def backoff_delay(ctx, error)
244
+ return nil unless ctx && error
245
+
246
+ @backoff.delay(ctx.deliveries.to_i, error)
247
+ end
212
248
  end
213
249
  end
@@ -80,8 +80,9 @@ module JetstreamBridge
80
80
  ack_policy: 'explicit',
81
81
  deliver_policy: 'all',
82
82
  max_deliver: JetstreamBridge.config.max_deliver,
83
- ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
84
- backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
83
+ # JetStream expects seconds (the client multiplies by nanoseconds).
84
+ ack_wait: duration_to_seconds(JetstreamBridge.config.ack_wait),
85
+ backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_seconds(d) }
85
86
  }
86
87
  end
87
88
 
@@ -93,8 +94,8 @@ module JetstreamBridge
93
94
  ack_policy: sval(cfg, :ack_policy), # string
94
95
  deliver_policy: sval(cfg, :deliver_policy), # string
95
96
  max_deliver: ival(cfg, :max_deliver), # integer
96
- ack_wait: d_ms(cfg, :ack_wait), # integer ms
97
- backoff_ms: darr_ms(cfg, :backoff) # array of integer ms
97
+ ack_wait_secs: d_secs(cfg, :ack_wait), # integer seconds
98
+ backoff_secs: darr_secs(cfg, :backoff) # array of integer seconds
98
99
  }
99
100
  end
100
101
 
@@ -156,40 +157,48 @@ module JetstreamBridge
156
157
  # - Integers/Floats:
157
158
  # * Server may return large integers in **nanoseconds** → detect and convert.
158
159
  # * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
159
- def d_ms(cfg, key)
160
+ def d_secs(cfg, key)
160
161
  raw = get(cfg, key)
161
- duration_to_ms(raw)
162
+ duration_to_seconds(raw)
162
163
  end
163
164
 
164
165
  # Normalize array of durations to integer milliseconds.
165
- def darr_ms(cfg, key)
166
+ def darr_secs(cfg, key)
166
167
  raw = get(cfg, key)
167
- Array(raw).map { |d| duration_to_ms(d) }
168
+ Array(raw).map { |d| duration_to_seconds(d) }
168
169
  end
169
170
 
170
171
  # ---- duration coercion ----
171
172
 
172
- def duration_to_ms(val)
173
+ def duration_to_seconds(val)
173
174
  return nil if val.nil?
174
175
 
175
176
  case val
176
177
  when Integer
177
178
  # Heuristic: extremely large integers are likely **nanoseconds** from server
178
- # (e.g., 30s => 30_000_000_000 ns). Convert ns → ms.
179
- return (val / 1_000_000.0).round if val >= 1_000_000_000
179
+ # (e.g., 30s => 30_000_000_000 ns). Convert ns → seconds.
180
+ return (val / 1_000_000_000.0).round if val >= 1_000_000_000
180
181
 
181
182
  # otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
182
- Duration.to_millis(val, default_unit: :auto)
183
+ millis = Duration.to_millis(val, default_unit: :auto)
184
+ seconds_from_millis(millis)
183
185
  when Float
184
- Duration.to_millis(val, default_unit: :auto) # treated as seconds
186
+ millis = Duration.to_millis(val, default_unit: :auto)
187
+ seconds_from_millis(millis)
185
188
  when String
186
189
  # Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
187
- Duration.to_millis(val) # default_unit ignored when unit given
190
+ millis = Duration.to_millis(val) # default_unit ignored when unit given
191
+ seconds_from_millis(millis)
188
192
  else
189
- return Duration.to_millis(val.to_f, default_unit: :auto) if val.respond_to?(:to_f)
193
+ return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
190
194
 
191
195
  raise ArgumentError, "invalid duration: #{val.inspect}"
192
196
  end
193
197
  end
198
+
199
+ def seconds_from_millis(millis)
200
+ # Always round up to avoid zero-second waits when sub-second durations are provided.
201
+ [(millis / 1000.0).ceil, 1].max
202
+ end
194
203
  end
195
204
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+ require_relative 'connection'
5
+
6
+ module JetstreamBridge
7
+ module Core
8
+ # Internal helper methods extracted from the main JetstreamBridge module
9
+ # to keep the public API surface focused.
10
+ module BridgeHelpers
11
+ private
12
+
13
+ # Ensure connection is established before use
14
+ # Automatically connects on first use if not already connected
15
+ # Thread-safe and idempotent
16
+ def connect_if_needed!
17
+ return if @connection_initialized
18
+
19
+ startup!
20
+ end
21
+
22
+ # Enforce rate limit on uncached health checks to prevent abuse
23
+ # Max 1 uncached request per 5 seconds per process
24
+ def enforce_health_check_rate_limit!
25
+ @health_check_mutex ||= Mutex.new
26
+ @health_check_mutex.synchronize do
27
+ now = Time.now
28
+ if @last_uncached_health_check
29
+ time_since = now - @last_uncached_health_check
30
+ if time_since < 5
31
+ raise HealthCheckFailedError,
32
+ "Health check rate limit exceeded. Please wait #{(5 - time_since).ceil} second(s)"
33
+ end
34
+ end
35
+ @last_uncached_health_check = now
36
+ end
37
+ end
38
+
39
+ def fetch_stream_info
40
+ # Ensure we have an active connection before querying stream info
41
+ connect_if_needed!
42
+
43
+ jts = Connection.jetstream
44
+ raise ConnectionNotEstablishedError, 'NATS connection not established' unless jts
45
+
46
+ info = jts.stream_info(config.stream_name)
47
+
48
+ # Handle both object-style and hash-style access for compatibility
49
+ config_data = info.config
50
+ state_data = info.state
51
+ subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
52
+ messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
53
+
54
+ {
55
+ exists: true,
56
+ name: config.stream_name,
57
+ subjects: subjects,
58
+ messages: messages
59
+ }
60
+ rescue StandardError => e
61
+ {
62
+ exists: false,
63
+ name: config.stream_name,
64
+ error: "#{e.class}: #{e.message}"
65
+ }
66
+ end
67
+
68
+ def measure_nats_rtt
69
+ # Measure round-trip time using NATS RTT method
70
+ nc = Connection.nc
71
+ start = Time.now
72
+ nc.rtt
73
+ ((Time.now - start) * 1000).round(2)
74
+ rescue StandardError => e
75
+ Logging.warn(
76
+ "Failed to measure NATS RTT: #{e.class} #{e.message}",
77
+ tag: 'JetstreamBridge'
78
+ )
79
+ nil
80
+ end
81
+
82
+ def assign_config_option!(cfg, key, val)
83
+ setter = :"#{key}="
84
+ raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
85
+
86
+ cfg.public_send(setter, val)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -97,6 +97,7 @@ module JetstreamBridge
97
97
  @jts
98
98
  rescue StandardError
99
99
  @state = State::FAILED
100
+ cleanup_connection!
100
101
  raise
101
102
  end
102
103
 
@@ -134,6 +135,9 @@ module JetstreamBridge
134
135
  # @return [Time, nil] timestamp when connection was established
135
136
  attr_reader :connected_at
136
137
 
138
+ # Last reconnection error metadata (exposed for health checks/diagnostics)
139
+ attr_reader :last_reconnect_error, :last_reconnect_error_at
140
+
137
141
  # Get current connection state
138
142
  #
139
143
  # @return [Symbol] Current connection state (see State module)
@@ -193,6 +197,7 @@ module JetstreamBridge
193
197
  "Failed to establish connection after #{attempts} attempts",
194
198
  tag: 'JetstreamBridge::Connection'
195
199
  )
200
+ cleanup_connection!
196
201
  raise
197
202
  end
198
203
  end
@@ -416,7 +421,7 @@ module JetstreamBridge
416
421
  @last_reconnect_error = e
417
422
  @last_reconnect_error_at = Time.now
418
423
  @state = State::FAILED
419
-
424
+ cleanup_connection!(close_nc: false)
420
425
  Logging.error(
421
426
  "Failed to refresh JetStream context: #{e.class} #{e.message}",
422
427
  tag: 'JetstreamBridge::Connection'
@@ -427,10 +432,6 @@ module JetstreamBridge
427
432
  @last_health_check = Time.now.to_i
428
433
  end
429
434
 
430
- # Get last reconnection error for diagnostics
431
- # @return [StandardError, nil] Last error during reconnection
432
- attr_reader :last_reconnect_error, :last_reconnect_error_at
433
-
434
435
  # Expose for class-level helpers (not part of public API)
435
436
  attr_reader :nc
436
437
 
@@ -444,5 +445,20 @@ module JetstreamBridge
444
445
  def sanitize_urls(urls)
445
446
  urls.map { |u| Logging.sanitize_url(u) }
446
447
  end
448
+
449
+ def cleanup_connection!(close_nc: true)
450
+ begin
451
+ # Avoid touching RSpec doubles used in unit tests
452
+ is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
453
+ @nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
454
+ rescue StandardError
455
+ # ignore cleanup errors
456
+ end
457
+ @nc = nil
458
+ @jts = nil
459
+ @cached_health_status = nil
460
+ @last_health_check = nil
461
+ @connected_at = nil
462
+ end
447
463
  end
448
464
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Core building blocks for JetstreamBridge
4
+ require_relative 'core/config'
5
+ require_relative 'core/duration'
6
+ require_relative 'core/logging'
7
+ require_relative 'core/connection'
8
+ require_relative 'core/bridge_helpers'
@@ -174,8 +174,8 @@ module JetstreamBridge
174
174
  def raise_missing_ar!(which, method_name)
175
175
  raise(
176
176
  "#{which} requires ActiveRecord (tried to call ##{method_name}). " \
177
- "Enable `use_inbox` only in apps with ActiveRecord, or add " \
178
- "`gem \"activerecord\"` to your Gemfile."
177
+ 'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
178
+ '`gem "activerecord"` to your Gemfile.'
179
179
  )
180
180
  end
181
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
- "Enable `use_outbox` only in apps with ActiveRecord, or add " \
173
- "`gem \"activerecord\"` to your Gemfile."
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,14 +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.configure.
40
- # If not, this will attempt to connect, but it's recommended to call configure first.
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
41
  #
42
42
  # @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
43
43
  # Defaults to PublisherRetryStrategy with exponential backoff.
44
- # @raise [ConnectionError] If unable to connect to NATS server
44
+ # @raise [ConnectionError] If unable to get JetStream connection
45
45
  def initialize(retry_strategy: nil)
46
- @jts = Connection.jetstream || Connection.connect!
46
+ @jts = Connection.jetstream
47
+ raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
48
+
47
49
  @retry_strategy = retry_strategy || PublisherRetryStrategy.new
48
50
  end
49
51
 
@@ -106,7 +108,7 @@ module JetstreamBridge
106
108
  # end
107
109
  #
108
110
  def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
109
- ensure_destination!
111
+ ensure_destination_app_configured!
110
112
 
111
113
  params = { event_or_hash: event_or_hash, resource_type: resource_type, event_type: event_type,
112
114
  payload: payload, subject: subject, options: options }
@@ -189,7 +191,7 @@ module JetstreamBridge
189
191
  normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
190
192
  end
191
193
 
192
- def ensure_destination!
194
+ def ensure_destination_app_configured!
193
195
  return unless JetstreamBridge.config.destination_app.to_s.empty?
194
196
 
195
197
  raise ArgumentError, 'destination_app must be configured'
@@ -0,0 +1,148 @@
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
+ rake_task?
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 'rake task' if rake_task?
81
+
82
+ 'unknown'
83
+ end
84
+
85
+ def env_disables_autostart?
86
+ value = ENV.fetch('JETSTREAM_BRIDGE_DISABLE_AUTOSTART', nil)
87
+ return false if value.nil?
88
+
89
+ normalized = value.to_s.strip.downcase
90
+ return false if normalized.empty?
91
+
92
+ !%w[false 0 no off].include?(normalized)
93
+ end
94
+
95
+ def force_autostart?
96
+ value = ENV.fetch('JETSTREAM_BRIDGE_FORCE_AUTOSTART', nil)
97
+ return false if value.nil?
98
+
99
+ normalized = value.to_s.strip.downcase
100
+ %w[true 1 yes on].include?(normalized)
101
+ end
102
+
103
+ def log_started!
104
+ active_logger&.info('[JetStream Bridge] Started successfully')
105
+ end
106
+
107
+ def log_development_connection_details!
108
+ conn_state = JetstreamBridge::Connection.instance.state
109
+ active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
110
+ active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
111
+ active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
112
+ active_logger&.info("[JetStream Bridge] Publishing to: #{JetstreamBridge.config.source_subject}")
113
+ active_logger&.info("[JetStream Bridge] Consuming from: #{JetstreamBridge.config.destination_subject}")
114
+ end
115
+
116
+ def register_shutdown_hook!
117
+ return if @shutdown_hook_registered
118
+
119
+ at_exit { JetstreamBridge.shutdown! }
120
+ @shutdown_hook_registered = true
121
+ end
122
+
123
+ def rails_test?
124
+ defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?
125
+ end
126
+
127
+ def rails_development?
128
+ defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.development?
129
+ end
130
+
131
+ def rails_console?
132
+ !!defined?(::Rails::Console)
133
+ end
134
+
135
+ def rake_task?
136
+ !!defined?(::Rake) || File.basename($PROGRAM_NAME) == 'rake'
137
+ end
138
+
139
+ def active_logger
140
+ if defined?(::Rails) && ::Rails.respond_to?(:logger)
141
+ ::Rails.logger
142
+ else
143
+ Logging.logger
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end