jetstream_bridge 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +22 -1427
- data/docs/GETTING_STARTED.md +92 -0
- data/docs/PRODUCTION.md +503 -0
- data/docs/TESTING.md +414 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +16 -4
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +17 -3
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +19 -7
- data/lib/jetstream_bridge/consumer/message_processor.rb +88 -52
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +24 -15
- data/lib/jetstream_bridge/core/bridge_helpers.rb +85 -0
- data/lib/jetstream_bridge/core/connection.rb +18 -1
- data/lib/jetstream_bridge/core.rb +8 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +2 -2
- data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
- data/lib/jetstream_bridge/publisher/publisher.rb +8 -6
- data/lib/jetstream_bridge/rails/integration.rb +153 -0
- data/lib/jetstream_bridge/rails/railtie.rb +53 -0
- data/lib/jetstream_bridge/rails.rb +5 -0
- data/lib/jetstream_bridge/tasks/install.rake +1 -1
- data/lib/jetstream_bridge/test_helpers/fixtures.rb +41 -0
- data/lib/jetstream_bridge/test_helpers/integration_helpers.rb +77 -0
- data/lib/jetstream_bridge/test_helpers/matchers.rb +98 -0
- data/lib/jetstream_bridge/test_helpers.rb +4 -259
- data/lib/jetstream_bridge/topology/stream.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +51 -93
- metadata +21 -8
- data/lib/jetstream_bridge/railtie.rb +0 -91
|
@@ -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
|
|
66
|
-
event = parse_message(msg, ctx)
|
|
67
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
ack_or_nak_action(msg, ctx, e)
|
|
140
128
|
end
|
|
141
129
|
|
|
142
|
-
def
|
|
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
|
|
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
|
-
|
|
181
|
-
msg.nak_with_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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
160
|
+
def d_secs(cfg, key)
|
|
160
161
|
raw = get(cfg, key)
|
|
161
|
-
|
|
162
|
+
duration_to_seconds(raw)
|
|
162
163
|
end
|
|
163
164
|
|
|
164
165
|
# Normalize array of durations to integer milliseconds.
|
|
165
|
-
def
|
|
166
|
+
def darr_secs(cfg, key)
|
|
166
167
|
raw = get(cfg, key)
|
|
167
|
-
Array(raw).map { |d|
|
|
168
|
+
Array(raw).map { |d| duration_to_seconds(d) }
|
|
168
169
|
end
|
|
169
170
|
|
|
170
171
|
# ---- duration coercion ----
|
|
171
172
|
|
|
172
|
-
def
|
|
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 →
|
|
179
|
-
return (val /
|
|
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)
|
|
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
|
|
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,85 @@
|
|
|
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
|
+
jts = Connection.jetstream
|
|
41
|
+
info = jts.stream_info(config.stream_name)
|
|
42
|
+
|
|
43
|
+
# Handle both object-style and hash-style access for compatibility
|
|
44
|
+
config_data = info.config
|
|
45
|
+
state_data = info.state
|
|
46
|
+
subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
|
|
47
|
+
messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
exists: true,
|
|
51
|
+
name: config.stream_name,
|
|
52
|
+
subjects: subjects,
|
|
53
|
+
messages: messages
|
|
54
|
+
}
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
{
|
|
57
|
+
exists: false,
|
|
58
|
+
name: config.stream_name,
|
|
59
|
+
error: "#{e.class}: #{e.message}"
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def measure_nats_rtt
|
|
64
|
+
# Measure round-trip time using NATS RTT method
|
|
65
|
+
nc = Connection.nc
|
|
66
|
+
start = Time.now
|
|
67
|
+
nc.rtt
|
|
68
|
+
((Time.now - start) * 1000).round(2)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
Logging.warn(
|
|
71
|
+
"Failed to measure NATS RTT: #{e.class} #{e.message}",
|
|
72
|
+
tag: 'JetstreamBridge'
|
|
73
|
+
)
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def assign_config_option!(cfg, key, val)
|
|
78
|
+
setter = :"#{key}="
|
|
79
|
+
raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
|
|
80
|
+
|
|
81
|
+
cfg.public_send(setter, val)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
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
|
|
|
@@ -193,6 +194,7 @@ module JetstreamBridge
|
|
|
193
194
|
"Failed to establish connection after #{attempts} attempts",
|
|
194
195
|
tag: 'JetstreamBridge::Connection'
|
|
195
196
|
)
|
|
197
|
+
cleanup_connection!
|
|
196
198
|
raise
|
|
197
199
|
end
|
|
198
200
|
end
|
|
@@ -416,7 +418,7 @@ module JetstreamBridge
|
|
|
416
418
|
@last_reconnect_error = e
|
|
417
419
|
@last_reconnect_error_at = Time.now
|
|
418
420
|
@state = State::FAILED
|
|
419
|
-
|
|
421
|
+
cleanup_connection!(close_nc: false)
|
|
420
422
|
Logging.error(
|
|
421
423
|
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
422
424
|
tag: 'JetstreamBridge::Connection'
|
|
@@ -444,5 +446,20 @@ module JetstreamBridge
|
|
|
444
446
|
def sanitize_urls(urls)
|
|
445
447
|
urls.map { |u| Logging.sanitize_url(u) }
|
|
446
448
|
end
|
|
449
|
+
|
|
450
|
+
def cleanup_connection!(close_nc: true)
|
|
451
|
+
begin
|
|
452
|
+
# Avoid touching RSpec doubles used in unit tests
|
|
453
|
+
is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
|
|
454
|
+
@nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
|
|
455
|
+
rescue StandardError
|
|
456
|
+
# ignore cleanup errors
|
|
457
|
+
end
|
|
458
|
+
@nc = nil
|
|
459
|
+
@jts = nil
|
|
460
|
+
@cached_health_status = nil
|
|
461
|
+
@last_health_check = nil
|
|
462
|
+
@connected_at = nil
|
|
463
|
+
end
|
|
447
464
|
end
|
|
448
465
|
end
|
|
@@ -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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
173
|
-
|
|
172
|
+
'Enable `use_outbox` only in apps with ActiveRecord, or add ' \
|
|
173
|
+
'`gem "activerecord"` to your Gemfile.'
|
|
174
174
|
)
|
|
175
175
|
end
|
|
176
176
|
end
|
|
@@ -36,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.
|
|
40
|
-
#
|
|
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
|
|
44
|
+
# @raise [ConnectionError] If unable to get JetStream connection
|
|
45
45
|
def initialize(retry_strategy: nil)
|
|
46
|
-
@jts = Connection.jetstream
|
|
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
|
-
|
|
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
|
|
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,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/model_codec_setup'
|
|
4
|
+
require_relative '../core/logging'
|
|
5
|
+
require_relative '../core/connection'
|
|
6
|
+
|
|
7
|
+
module JetstreamBridge
|
|
8
|
+
module Rails
|
|
9
|
+
# Rails-specific lifecycle helpers for JetStream Bridge.
|
|
10
|
+
#
|
|
11
|
+
# Keeps the Railtie thin and makes lifecycle decisions easy to test and reason about.
|
|
12
|
+
module Integration
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Configure logger to use Rails.logger when available.
|
|
16
|
+
def configure_logger!
|
|
17
|
+
JetstreamBridge.configure do |config|
|
|
18
|
+
config.logger ||= ::Rails.logger if defined?(::Rails.logger)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Attach ActiveRecord hooks for serializer setup on reload.
|
|
23
|
+
def attach_active_record_hooks!
|
|
24
|
+
ActiveSupport.on_load(:active_record) do
|
|
25
|
+
ActiveSupport::Reloader.to_prepare { JetstreamBridge::ModelCodecSetup.apply! }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Validate config, enable test mode if appropriate, and start the bridge unless auto-start is disabled.
|
|
30
|
+
def boot_bridge!
|
|
31
|
+
auto_enable_test_mode!
|
|
32
|
+
|
|
33
|
+
if autostart_disabled?
|
|
34
|
+
message = "Auto-start skipped (reason: #{autostart_skip_reason}; " \
|
|
35
|
+
'enable via lazy_connect=false, unset JETSTREAM_BRIDGE_DISABLE_AUTOSTART, ' \
|
|
36
|
+
'or set JETSTREAM_BRIDGE_FORCE_AUTOSTART=1)'
|
|
37
|
+
Logging.info(message, tag: 'JetstreamBridge::Railtie')
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
JetstreamBridge.config.validate!
|
|
42
|
+
JetstreamBridge.startup!
|
|
43
|
+
log_started!
|
|
44
|
+
log_development_connection_details! if rails_development?
|
|
45
|
+
register_shutdown_hook!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Auto-enable test mode in test environment when NATS is not configured.
|
|
49
|
+
def auto_enable_test_mode!
|
|
50
|
+
return unless auto_enable_test_mode?
|
|
51
|
+
|
|
52
|
+
Logging.info(
|
|
53
|
+
'[JetStream Bridge] Auto-enabling test mode (NATS_URLS not set)',
|
|
54
|
+
tag: 'JetstreamBridge::Railtie'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
require_relative '../test_helpers'
|
|
58
|
+
JetstreamBridge::TestHelpers.enable_test_mode!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def auto_enable_test_mode?
|
|
62
|
+
rails_test? &&
|
|
63
|
+
ENV['NATS_URLS'].to_s.strip.empty? &&
|
|
64
|
+
!(defined?(JetstreamBridge::TestHelpers) &&
|
|
65
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
66
|
+
JetstreamBridge::TestHelpers.test_mode?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def autostart_disabled?
|
|
70
|
+
return false if force_autostart?
|
|
71
|
+
|
|
72
|
+
JetstreamBridge.config.lazy_connect ||
|
|
73
|
+
env_disables_autostart? ||
|
|
74
|
+
skip_autostart_for_rails_tooling?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def autostart_skip_reason
|
|
78
|
+
return 'lazy_connect enabled' if JetstreamBridge.config.lazy_connect
|
|
79
|
+
return 'JETSTREAM_BRIDGE_DISABLE_AUTOSTART set' if env_disables_autostart?
|
|
80
|
+
return 'Rails console' if rails_console?
|
|
81
|
+
return 'rake task' if rake_task?
|
|
82
|
+
|
|
83
|
+
'unknown'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def env_disables_autostart?
|
|
87
|
+
value = ENV.fetch('JETSTREAM_BRIDGE_DISABLE_AUTOSTART', nil)
|
|
88
|
+
return false if value.nil?
|
|
89
|
+
|
|
90
|
+
normalized = value.to_s.strip.downcase
|
|
91
|
+
return false if normalized.empty?
|
|
92
|
+
|
|
93
|
+
!%w[false 0 no off].include?(normalized)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def force_autostart?
|
|
97
|
+
value = ENV.fetch('JETSTREAM_BRIDGE_FORCE_AUTOSTART', nil)
|
|
98
|
+
return false if value.nil?
|
|
99
|
+
|
|
100
|
+
normalized = value.to_s.strip.downcase
|
|
101
|
+
%w[true 1 yes on].include?(normalized)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def log_started!
|
|
105
|
+
active_logger&.info('[JetStream Bridge] Started successfully')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def log_development_connection_details!
|
|
109
|
+
conn_state = JetstreamBridge::Connection.instance.state
|
|
110
|
+
active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
|
|
111
|
+
active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
|
|
112
|
+
active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
|
|
113
|
+
active_logger&.info("[JetStream Bridge] Publishing to: #{JetstreamBridge.config.source_subject}")
|
|
114
|
+
active_logger&.info("[JetStream Bridge] Consuming from: #{JetstreamBridge.config.destination_subject}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def register_shutdown_hook!
|
|
118
|
+
return if @shutdown_hook_registered
|
|
119
|
+
|
|
120
|
+
at_exit { JetstreamBridge.shutdown! }
|
|
121
|
+
@shutdown_hook_registered = true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def rails_test?
|
|
125
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def rails_development?
|
|
129
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.development?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def rails_console?
|
|
133
|
+
!!defined?(::Rails::Console)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def rake_task?
|
|
137
|
+
!!defined?(::Rake) || File.basename($PROGRAM_NAME) == 'rake'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def skip_autostart_for_rails_tooling?
|
|
141
|
+
rails_console? || rake_task?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def active_logger
|
|
145
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
146
|
+
::Rails.logger
|
|
147
|
+
else
|
|
148
|
+
Logging.logger
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|