jetstream_bridge 4.0.4 → 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 +106 -0
- data/README.md +22 -1402
- 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 +101 -5
- 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/config.rb +27 -4
- data/lib/jetstream_bridge/core/connection.rb +162 -13
- data/lib/jetstream_bridge/core.rb +8 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +13 -7
- data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
- data/lib/jetstream_bridge/publisher/publisher.rb +10 -5
- 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/mock_nats.rb +524 -0
- data/lib/jetstream_bridge/test_helpers.rb +85 -121
- data/lib/jetstream_bridge/topology/overlap_guard.rb +46 -0
- data/lib/jetstream_bridge/topology/stream.rb +7 -4
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +138 -63
- metadata +32 -12
- data/lib/jetstream_bridge/railtie.rb +0 -49
|
@@ -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
|
|
@@ -86,6 +86,15 @@ module JetstreamBridge
|
|
|
86
86
|
# Applied preset name
|
|
87
87
|
# @return [Symbol, nil]
|
|
88
88
|
attr_reader :preset_applied
|
|
89
|
+
# Number of retry attempts for initial connection
|
|
90
|
+
# @return [Integer]
|
|
91
|
+
attr_accessor :connect_retry_attempts
|
|
92
|
+
# Delay between connection retry attempts (in seconds)
|
|
93
|
+
# @return [Integer]
|
|
94
|
+
attr_accessor :connect_retry_delay
|
|
95
|
+
# Enable lazy connection (connect on first use instead of during configure)
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
attr_accessor :lazy_connect
|
|
89
98
|
|
|
90
99
|
def initialize
|
|
91
100
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
@@ -104,6 +113,11 @@ module JetstreamBridge
|
|
|
104
113
|
@inbox_model = 'JetstreamBridge::InboxEvent'
|
|
105
114
|
@logger = nil
|
|
106
115
|
@preset_applied = nil
|
|
116
|
+
|
|
117
|
+
# Connection management
|
|
118
|
+
@connect_retry_attempts = 3
|
|
119
|
+
@connect_retry_delay = 2
|
|
120
|
+
@lazy_connect = false
|
|
107
121
|
end
|
|
108
122
|
|
|
109
123
|
# Apply a configuration preset
|
|
@@ -219,11 +233,20 @@ module JetstreamBridge
|
|
|
219
233
|
private
|
|
220
234
|
|
|
221
235
|
def validate_subject_component!(value, name)
|
|
222
|
-
str = value.to_s
|
|
223
|
-
if str.
|
|
224
|
-
|
|
236
|
+
str = value.to_s.strip
|
|
237
|
+
raise MissingConfigurationError, "#{name} cannot be empty" if str.empty?
|
|
238
|
+
|
|
239
|
+
# NATS subject tokens must not contain wildcards, spaces, or control characters
|
|
240
|
+
# Valid characters: alphanumeric, hyphen, underscore
|
|
241
|
+
if str.match?(/[.*>\s\x00-\x1F\x7F]/)
|
|
242
|
+
raise InvalidSubjectError,
|
|
243
|
+
"#{name} contains invalid NATS subject characters (wildcards, spaces, or control chars): #{value.inspect}"
|
|
225
244
|
end
|
|
226
|
-
|
|
245
|
+
|
|
246
|
+
# NATS has a practical subject length limit
|
|
247
|
+
return unless str.length > 255
|
|
248
|
+
|
|
249
|
+
raise InvalidSubjectError, "#{name} exceeds maximum length (255 characters): #{str.length}"
|
|
227
250
|
end
|
|
228
251
|
end
|
|
229
252
|
end
|