jetstream_bridge 5.1.0 → 7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +6 -3
- data/docs/API.md +395 -0
- data/docs/ARCHITECTURE.md +123 -171
- data/docs/GETTING_STARTED.md +72 -1
- data/docs/PRODUCTION.md +10 -3
- data/docs/RESTRICTED_PERMISSIONS.md +7 -14
- data/docs/TESTING.md +3 -3
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +29 -13
- data/lib/jetstream_bridge/config_helpers/lifecycle.rb +34 -0
- data/lib/jetstream_bridge/config_helpers.rb +118 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +131 -41
- data/lib/jetstream_bridge/consumer/consumer_state.rb +58 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +12 -2
- data/lib/jetstream_bridge/consumer/pull_subscription_builder.rb +6 -6
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +72 -110
- data/lib/jetstream_bridge/core/config.rb +31 -0
- data/lib/jetstream_bridge/core/connection.rb +97 -31
- data/lib/jetstream_bridge/core/consumer_mode_resolver.rb +64 -0
- data/lib/jetstream_bridge/core/duration.rb +30 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +1 -1
- data/lib/jetstream_bridge/models/outbox_event.rb +1 -1
- data/lib/jetstream_bridge/provisioner.rb +108 -13
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +35 -20
- data/lib/jetstream_bridge/publisher/publisher.rb +4 -4
- data/lib/jetstream_bridge/tasks/install.rake +2 -2
- data/lib/jetstream_bridge/topology/stream.rb +6 -1
- data/lib/jetstream_bridge/topology/topology.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +8 -12
- metadata +7 -2
|
@@ -41,60 +41,68 @@ module JetstreamBridge
|
|
|
41
41
|
# Bind a subscriber to the existing durable consumer.
|
|
42
42
|
def subscribe!
|
|
43
43
|
if @cfg.push_consumer?
|
|
44
|
-
|
|
44
|
+
subscribe_push_with_fallback
|
|
45
45
|
else
|
|
46
|
-
|
|
47
|
-
subscribe_without_verification!
|
|
46
|
+
subscribe_pull_with_fallback
|
|
48
47
|
end
|
|
49
48
|
end
|
|
50
49
|
|
|
51
50
|
def subscribe_without_verification!
|
|
52
51
|
# Manually create a pull subscription without calling consumer_info
|
|
53
52
|
# This bypasses the permission check in nats-pure's pull_subscribe
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
tag: 'JetstreamBridge::Consumer'
|
|
63
|
-
)
|
|
64
|
-
return @jts.pull_subscribe(filter_subject, @durable, stream: stream_name)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
raise JetstreamBridge::ConnectionError,
|
|
68
|
-
'Unable to create subscription without verification: NATS client not available'
|
|
53
|
+
create_subscription_with_fallback(
|
|
54
|
+
description: "pull subscription for consumer #{@durable} (stream=#{stream_name})",
|
|
55
|
+
primary_check: ->(nc) { nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe) },
|
|
56
|
+
primary_action: ->(nc) { build_pull_subscription(nc) },
|
|
57
|
+
fallback_name: :pull_subscribe,
|
|
58
|
+
fallback_available: -> { @jts.respond_to?(:pull_subscribe) },
|
|
59
|
+
fallback_action: -> { @jts.pull_subscribe(filter_subject, @durable, stream: stream_name) }
|
|
60
|
+
)
|
|
69
61
|
end
|
|
70
62
|
|
|
71
63
|
def subscribe_push!
|
|
72
64
|
# Push consumers deliver messages directly to a subscription subject
|
|
73
65
|
# No JetStream API calls needed - just subscribe to the delivery subject
|
|
74
|
-
nc = resolve_nc
|
|
75
66
|
delivery_subject = @cfg.push_delivery_subject
|
|
67
|
+
queue_group = @cfg.push_consumer_group_name
|
|
68
|
+
|
|
69
|
+
create_subscription_with_fallback(
|
|
70
|
+
description: "push subscription for consumer #{@durable} " \
|
|
71
|
+
"(stream=#{stream_name}, delivery=#{delivery_subject}, queue=#{queue_group})",
|
|
72
|
+
primary_check: ->(nc) { nc.respond_to?(:subscribe) },
|
|
73
|
+
primary_action: lambda do |nc|
|
|
74
|
+
sub = nc.subscribe(delivery_subject, queue: queue_group)
|
|
75
|
+
Logging.info(
|
|
76
|
+
"Created push subscription for consumer #{@durable} " \
|
|
77
|
+
"(stream=#{stream_name}, delivery=#{delivery_subject}, queue=#{queue_group})",
|
|
78
|
+
tag: 'JetstreamBridge::Consumer'
|
|
79
|
+
)
|
|
80
|
+
sub
|
|
81
|
+
end,
|
|
82
|
+
fallback_name: :subscribe,
|
|
83
|
+
fallback_available: -> { @jts.respond_to?(:subscribe) },
|
|
84
|
+
fallback_action: -> { @jts.subscribe(delivery_subject, queue: queue_group) }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
76
87
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
# Fallback for test environments
|
|
88
|
-
if @jts.respond_to?(:subscribe)
|
|
89
|
-
Logging.info(
|
|
90
|
-
"Using JetStream subscribe fallback for push consumer #{@durable} (stream=#{stream_name})",
|
|
91
|
-
tag: 'JetstreamBridge::Consumer'
|
|
92
|
-
)
|
|
93
|
-
return @jts.subscribe(delivery_subject)
|
|
94
|
-
end
|
|
88
|
+
def subscribe_push_with_fallback
|
|
89
|
+
subscribe_push!
|
|
90
|
+
rescue JetstreamBridge::ConnectionError, StandardError => e
|
|
91
|
+
Logging.warn(
|
|
92
|
+
"Push subscription failed (#{e.class}: #{e.message}); falling back to pull subscription for #{@durable}",
|
|
93
|
+
tag: 'JetstreamBridge::Consumer'
|
|
94
|
+
)
|
|
95
|
+
subscribe_without_verification!
|
|
96
|
+
end
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
def subscribe_pull_with_fallback
|
|
99
|
+
subscribe_without_verification!
|
|
100
|
+
rescue JetstreamBridge::ConnectionError, StandardError => e
|
|
101
|
+
Logging.warn(
|
|
102
|
+
"Pull subscription failed (#{e.class}: #{e.message}); falling back to push subscription for #{@durable}",
|
|
103
|
+
tag: 'JetstreamBridge::Consumer'
|
|
104
|
+
)
|
|
105
|
+
subscribe_push!
|
|
98
106
|
end
|
|
99
107
|
|
|
100
108
|
private
|
|
@@ -107,12 +115,15 @@ module JetstreamBridge
|
|
|
107
115
|
deliver_policy: 'all',
|
|
108
116
|
max_deliver: JetstreamBridge.config.max_deliver,
|
|
109
117
|
# JetStream expects seconds (the client multiplies by nanoseconds).
|
|
110
|
-
ack_wait:
|
|
111
|
-
backoff:
|
|
118
|
+
ack_wait: Duration.to_seconds(JetstreamBridge.config.ack_wait),
|
|
119
|
+
backoff: Duration.normalize_list_to_seconds(JetstreamBridge.config.backoff)
|
|
112
120
|
}
|
|
113
121
|
|
|
114
|
-
# Add deliver_subject for push consumers
|
|
115
|
-
|
|
122
|
+
# Add deliver_subject and deliver_group for push consumers
|
|
123
|
+
if @cfg.push_consumer?
|
|
124
|
+
config[:deliver_subject] = @cfg.push_delivery_subject
|
|
125
|
+
config[:deliver_group] = @cfg.push_consumer_group_name
|
|
126
|
+
end
|
|
116
127
|
|
|
117
128
|
config
|
|
118
129
|
end
|
|
@@ -125,73 +136,6 @@ module JetstreamBridge
|
|
|
125
136
|
)
|
|
126
137
|
end
|
|
127
138
|
|
|
128
|
-
# ---- cfg access/normalization (struct-like or hash-like) ----
|
|
129
|
-
|
|
130
|
-
def get(cfg, key)
|
|
131
|
-
cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def sval(cfg, key)
|
|
135
|
-
v = get(cfg, key)
|
|
136
|
-
v = v.to_s if v.is_a?(Symbol)
|
|
137
|
-
v&.to_s&.downcase
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def ival(cfg, key)
|
|
141
|
-
v = get(cfg, key)
|
|
142
|
-
v.to_i
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Normalize duration-like field to **milliseconds** (Integer).
|
|
146
|
-
# Accepts:
|
|
147
|
-
# - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
|
|
148
|
-
# - Integers/Floats:
|
|
149
|
-
# * Server may return large integers in **nanoseconds** → detect and convert.
|
|
150
|
-
# * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
|
|
151
|
-
def d_secs(cfg, key)
|
|
152
|
-
raw = get(cfg, key)
|
|
153
|
-
duration_to_seconds(raw)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Normalize array of durations to integer milliseconds.
|
|
157
|
-
def darr_secs(cfg, key)
|
|
158
|
-
raw = get(cfg, key)
|
|
159
|
-
Array(raw).map { |d| duration_to_seconds(d) }
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# ---- duration coercion ----
|
|
163
|
-
|
|
164
|
-
def duration_to_seconds(val)
|
|
165
|
-
return nil if val.nil?
|
|
166
|
-
|
|
167
|
-
case val
|
|
168
|
-
when Integer
|
|
169
|
-
# Heuristic: extremely large integers are likely **nanoseconds** from server
|
|
170
|
-
# (e.g., 30s => 30_000_000_000 ns). Convert ns → seconds.
|
|
171
|
-
return (val / 1_000_000_000.0).round if val >= 1_000_000_000
|
|
172
|
-
|
|
173
|
-
# otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
|
|
174
|
-
millis = Duration.to_millis(val, default_unit: :auto)
|
|
175
|
-
seconds_from_millis(millis)
|
|
176
|
-
when Float
|
|
177
|
-
millis = Duration.to_millis(val, default_unit: :auto)
|
|
178
|
-
seconds_from_millis(millis)
|
|
179
|
-
when String
|
|
180
|
-
# Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
|
|
181
|
-
millis = Duration.to_millis(val) # default_unit ignored when unit given
|
|
182
|
-
seconds_from_millis(millis)
|
|
183
|
-
else
|
|
184
|
-
return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
|
|
185
|
-
|
|
186
|
-
raise ArgumentError, "invalid duration: #{val.inspect}"
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def seconds_from_millis(millis)
|
|
191
|
-
# Always round up to avoid zero-second waits when sub-second durations are provided.
|
|
192
|
-
[(millis / 1000.0).ceil, 1].max
|
|
193
|
-
end
|
|
194
|
-
|
|
195
139
|
def log_runtime_skip
|
|
196
140
|
Logging.info(
|
|
197
141
|
"Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
|
|
@@ -213,5 +157,23 @@ module JetstreamBridge
|
|
|
213
157
|
builder = PullSubscriptionBuilder.new(@jts, @durable, stream_name, filter_subject)
|
|
214
158
|
builder.build(nats_client)
|
|
215
159
|
end
|
|
160
|
+
|
|
161
|
+
def create_subscription_with_fallback(description:, primary_check:, primary_action:, fallback_name:,
|
|
162
|
+
fallback_available:, fallback_action:)
|
|
163
|
+
nc = resolve_nc
|
|
164
|
+
|
|
165
|
+
return primary_action.call(nc) if nc && primary_check.call(nc)
|
|
166
|
+
|
|
167
|
+
if fallback_available.call
|
|
168
|
+
Logging.info(
|
|
169
|
+
"Using #{fallback_name} fallback for #{description}",
|
|
170
|
+
tag: 'JetstreamBridge::Consumer'
|
|
171
|
+
)
|
|
172
|
+
return fallback_action.call
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
raise JetstreamBridge::ConnectionError,
|
|
176
|
+
"Unable to create #{description}: NATS client not available"
|
|
177
|
+
end
|
|
216
178
|
end
|
|
217
179
|
end
|
|
@@ -114,6 +114,10 @@ module JetstreamBridge
|
|
|
114
114
|
# Only used when consumer_mode is :push
|
|
115
115
|
# @return [String, nil]
|
|
116
116
|
attr_accessor :delivery_subject
|
|
117
|
+
# Queue group / deliver_group for push consumers (optional, defaults to durable_name or app_name)
|
|
118
|
+
# Only used when consumer_mode is :push. Determines how push consumers load-balance.
|
|
119
|
+
# @return [String, nil]
|
|
120
|
+
attr_accessor :push_consumer_group
|
|
117
121
|
|
|
118
122
|
def initialize
|
|
119
123
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
@@ -142,6 +146,7 @@ module JetstreamBridge
|
|
|
142
146
|
# Consumer mode
|
|
143
147
|
@consumer_mode = :pull
|
|
144
148
|
@delivery_subject = nil
|
|
149
|
+
@push_consumer_group = nil
|
|
145
150
|
end
|
|
146
151
|
|
|
147
152
|
# Apply a configuration preset
|
|
@@ -223,11 +228,26 @@ module JetstreamBridge
|
|
|
223
228
|
"#{destination_subject}.worker"
|
|
224
229
|
end
|
|
225
230
|
|
|
231
|
+
# Queue group for push consumers. Controls deliver_group and queue subscription.
|
|
232
|
+
#
|
|
233
|
+
# @return [String] Queue group name
|
|
234
|
+
# @raise [InvalidSubjectError, MissingConfigurationError] If derived components invalid
|
|
235
|
+
def push_consumer_group_name
|
|
236
|
+
group = push_consumer_group
|
|
237
|
+
group = durable_name if group.to_s.strip.empty?
|
|
238
|
+
group = app_name if group.to_s.strip.empty?
|
|
239
|
+
|
|
240
|
+
validate_subject_component!(group, 'push_consumer_group')
|
|
241
|
+
group
|
|
242
|
+
end
|
|
243
|
+
|
|
226
244
|
# Check if using pull consumer mode.
|
|
227
245
|
#
|
|
228
246
|
# @return [Boolean]
|
|
229
247
|
def pull_consumer?
|
|
230
248
|
consumer_mode.to_sym == :pull
|
|
249
|
+
rescue NoMethodError
|
|
250
|
+
false
|
|
231
251
|
end
|
|
232
252
|
|
|
233
253
|
# Check if using push consumer mode.
|
|
@@ -235,6 +255,8 @@ module JetstreamBridge
|
|
|
235
255
|
# @return [Boolean]
|
|
236
256
|
def push_consumer?
|
|
237
257
|
consumer_mode.to_sym == :push
|
|
258
|
+
rescue NoMethodError
|
|
259
|
+
false
|
|
238
260
|
end
|
|
239
261
|
|
|
240
262
|
# Validate all configuration settings.
|
|
@@ -252,6 +274,7 @@ module JetstreamBridge
|
|
|
252
274
|
validate_numeric_constraints!(errors)
|
|
253
275
|
validate_backoff!(errors)
|
|
254
276
|
validate_consumer_mode!(errors)
|
|
277
|
+
validate_push_consumer!(errors)
|
|
255
278
|
|
|
256
279
|
raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
|
|
257
280
|
|
|
@@ -298,5 +321,13 @@ module JetstreamBridge
|
|
|
298
321
|
|
|
299
322
|
errors << 'consumer_mode must be :pull or :push' unless [:pull, :push].include?(consumer_mode.to_sym)
|
|
300
323
|
end
|
|
324
|
+
|
|
325
|
+
def validate_push_consumer!(errors)
|
|
326
|
+
return unless push_consumer?
|
|
327
|
+
|
|
328
|
+
push_consumer_group_name
|
|
329
|
+
rescue ConfigurationError => e
|
|
330
|
+
errors << e.message
|
|
331
|
+
end
|
|
301
332
|
end
|
|
302
333
|
end
|
|
@@ -44,6 +44,9 @@ module JetstreamBridge
|
|
|
44
44
|
}.freeze
|
|
45
45
|
|
|
46
46
|
VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
|
|
47
|
+
REFRESH_RETRY_BASE_DELAY = 0.01
|
|
48
|
+
REFRESH_RETRY_MAX_DELAY = 30.0
|
|
49
|
+
REFRESH_RETRY_MAX_ATTEMPTS = 30
|
|
47
50
|
|
|
48
51
|
# Class-level mutex for thread-safe connection initialization
|
|
49
52
|
# Using class variable to avoid race condition in mutex creation
|
|
@@ -238,36 +241,10 @@ module JetstreamBridge
|
|
|
238
241
|
tag: 'JetstreamBridge::Connection'
|
|
239
242
|
)
|
|
240
243
|
|
|
241
|
-
|
|
242
|
-
max_attempts = 3
|
|
243
|
-
attempts = 0
|
|
244
|
-
success = false
|
|
245
|
-
|
|
246
|
-
while attempts < max_attempts && !success
|
|
247
|
-
attempts += 1
|
|
248
|
-
begin
|
|
249
|
-
refresh_jetstream_context
|
|
250
|
-
success = true
|
|
251
|
-
rescue StandardError => e
|
|
252
|
-
if attempts < max_attempts
|
|
253
|
-
delay = 0.5 * (2**(attempts - 1)) # 0.5s, 1s, 2s
|
|
254
|
-
Logging.warn(
|
|
255
|
-
"JetStream context refresh attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
256
|
-
"Retrying in #{delay}s...",
|
|
257
|
-
tag: 'JetstreamBridge::Connection'
|
|
258
|
-
)
|
|
259
|
-
sleep(delay)
|
|
260
|
-
else
|
|
261
|
-
Logging.error(
|
|
262
|
-
"Failed to refresh JetStream context after #{attempts} attempts. " \
|
|
263
|
-
'Will retry on next reconnect.',
|
|
264
|
-
tag: 'JetstreamBridge::Connection'
|
|
265
|
-
)
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
end
|
|
244
|
+
success = refresh_jetstream_with_retry?
|
|
269
245
|
|
|
270
246
|
@reconnecting = false
|
|
247
|
+
start_refresh_retry_loop unless success
|
|
271
248
|
end
|
|
272
249
|
|
|
273
250
|
@nc.on_disconnect do |reason|
|
|
@@ -304,7 +281,7 @@ module JetstreamBridge
|
|
|
304
281
|
if verify_js
|
|
305
282
|
verify_jetstream!
|
|
306
283
|
if config_auto_provision
|
|
307
|
-
Topology.
|
|
284
|
+
Topology.provision!(@jts)
|
|
308
285
|
Logging.info(
|
|
309
286
|
'Topology ensured after connection (auto_provision=true).',
|
|
310
287
|
tag: 'JetstreamBridge::Connection'
|
|
@@ -461,7 +438,7 @@ module JetstreamBridge
|
|
|
461
438
|
|
|
462
439
|
# Re-ensure topology after reconnect when allowed
|
|
463
440
|
if config_auto_provision
|
|
464
|
-
Topology.
|
|
441
|
+
Topology.provision!(@jts)
|
|
465
442
|
else
|
|
466
443
|
Logging.info(
|
|
467
444
|
'Skipping topology provisioning after reconnect (auto_provision=false).',
|
|
@@ -504,11 +481,95 @@ module JetstreamBridge
|
|
|
504
481
|
raise
|
|
505
482
|
end
|
|
506
483
|
|
|
484
|
+
def refresh_jetstream_with_retry?(max_attempts: 3, base_delay: 0.5)
|
|
485
|
+
attempts = 0
|
|
486
|
+
while attempts < max_attempts
|
|
487
|
+
attempts += 1
|
|
488
|
+
begin
|
|
489
|
+
refresh_jetstream_context
|
|
490
|
+
return true
|
|
491
|
+
rescue StandardError => e
|
|
492
|
+
if attempts < max_attempts
|
|
493
|
+
delay = refresh_retry_sleep_duration(attempts, base: base_delay)
|
|
494
|
+
Logging.warn(
|
|
495
|
+
"JetStream context refresh attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
496
|
+
"Retrying in #{delay}s...",
|
|
497
|
+
tag: 'JetstreamBridge::Connection'
|
|
498
|
+
)
|
|
499
|
+
sleep(delay)
|
|
500
|
+
else
|
|
501
|
+
Logging.error(
|
|
502
|
+
"Failed to refresh JetStream context after #{attempts} attempts. " \
|
|
503
|
+
'Starting background retry loop.',
|
|
504
|
+
tag: 'JetstreamBridge::Connection'
|
|
505
|
+
)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
false
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def start_refresh_retry_loop(initial_delay: REFRESH_RETRY_BASE_DELAY, max_attempts: REFRESH_RETRY_MAX_ATTEMPTS)
|
|
514
|
+
return if @refresh_retry_thread&.alive?
|
|
515
|
+
return unless @nc
|
|
516
|
+
|
|
517
|
+
@refresh_retry_thread = Thread.new do
|
|
518
|
+
Thread.current.report_on_exception = false
|
|
519
|
+
attempts = 0
|
|
520
|
+
delay = initial_delay
|
|
521
|
+
|
|
522
|
+
while @nc&.connected?
|
|
523
|
+
sleep(delay)
|
|
524
|
+
attempts += 1
|
|
525
|
+
break if max_attempts && attempts > max_attempts
|
|
526
|
+
|
|
527
|
+
begin
|
|
528
|
+
refresh_jetstream_context
|
|
529
|
+
Logging.info(
|
|
530
|
+
"JetStream context refreshed via background retry after #{attempts} attempt#{'s' if attempts != 1}",
|
|
531
|
+
tag: 'JetstreamBridge::Connection'
|
|
532
|
+
)
|
|
533
|
+
break
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
if defined?(RSpec::Mocks::ExpiredTestDoubleError) &&
|
|
536
|
+
e.is_a?(RSpec::Mocks::ExpiredTestDoubleError)
|
|
537
|
+
Logging.debug(
|
|
538
|
+
'Stopping background JetStream refresh due to expired test double',
|
|
539
|
+
tag: 'JetstreamBridge::Connection'
|
|
540
|
+
)
|
|
541
|
+
break
|
|
542
|
+
end
|
|
543
|
+
delay = refresh_retry_sleep_duration(attempts + 1)
|
|
544
|
+
Logging.warn(
|
|
545
|
+
"Background JetStream refresh attempt #{attempts} failed: #{e.message}. " \
|
|
546
|
+
"Retrying in #{delay}s...",
|
|
547
|
+
tag: 'JetstreamBridge::Connection'
|
|
548
|
+
)
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
ensure
|
|
552
|
+
@refresh_retry_thread = nil
|
|
553
|
+
end
|
|
554
|
+
rescue StandardError => e
|
|
555
|
+
Logging.debug(
|
|
556
|
+
"Could not start refresh retry loop: #{e.class} #{e.message}",
|
|
557
|
+
tag: 'JetstreamBridge::Connection'
|
|
558
|
+
)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def refresh_retry_sleep_duration(attempt, base: REFRESH_RETRY_BASE_DELAY)
|
|
562
|
+
[base * (2**(attempt - 1)), REFRESH_RETRY_MAX_DELAY].min
|
|
563
|
+
end
|
|
564
|
+
|
|
507
565
|
# Expose for class-level helpers (not part of public API)
|
|
508
566
|
attr_reader :nc
|
|
509
567
|
|
|
510
568
|
def jetstream
|
|
511
|
-
@jts
|
|
569
|
+
return @jts if @jts
|
|
570
|
+
|
|
571
|
+
raise ConnectionNotEstablishedError,
|
|
572
|
+
'JetStream context unavailable (refresh pending or failed)'
|
|
512
573
|
end
|
|
513
574
|
|
|
514
575
|
# Mask credentials in NATS URLs:
|
|
@@ -539,6 +600,11 @@ module JetstreamBridge
|
|
|
539
600
|
@jts = nil
|
|
540
601
|
end
|
|
541
602
|
|
|
603
|
+
if @refresh_retry_thread&.alive?
|
|
604
|
+
@refresh_retry_thread.kill
|
|
605
|
+
@refresh_retry_thread = nil
|
|
606
|
+
end
|
|
607
|
+
|
|
542
608
|
# Always invalidate health check cache
|
|
543
609
|
@cached_health_status = nil
|
|
544
610
|
@last_health_check = nil
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module ConsumerModeResolver
|
|
5
|
+
VALID_MODES = [:pull, :push].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Resolve consumer mode for a given app.
|
|
10
|
+
#
|
|
11
|
+
# Priority:
|
|
12
|
+
# 1) explicit override (symbol/string)
|
|
13
|
+
# 2) env map CONSUMER_MODES="app:mode,..."
|
|
14
|
+
# 3) per-app env CONSUMER_MODE_<APP_NAME>
|
|
15
|
+
# 4) shared env CONSUMER_MODE
|
|
16
|
+
# 5) fallback (default :pull)
|
|
17
|
+
def resolve(app_name:, override: nil, fallback: :pull)
|
|
18
|
+
return normalize(override) if override
|
|
19
|
+
|
|
20
|
+
from_env = env_for(app_name)
|
|
21
|
+
return from_env if from_env
|
|
22
|
+
|
|
23
|
+
normalize(fallback)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def env_for(app_name)
|
|
27
|
+
from_map(app_name) || app_specific(app_name) || shared
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def from_map(app_name)
|
|
31
|
+
env_map = ENV.fetch('CONSUMER_MODES', nil)
|
|
32
|
+
return nil if env_map.to_s.strip.empty?
|
|
33
|
+
|
|
34
|
+
pairs = env_map.split(',').each_with_object({}) do |pair, memo|
|
|
35
|
+
key, mode = pair.split(':', 2).map { |p| p&.strip }
|
|
36
|
+
memo[key] = mode if key && mode
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
normalize(pairs[app_name.to_s]) if pairs.key?(app_name.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def app_specific(app_name)
|
|
43
|
+
key = "CONSUMER_MODE_#{app_name.to_s.upcase}"
|
|
44
|
+
return nil unless ENV.key?(key)
|
|
45
|
+
|
|
46
|
+
normalize(ENV.fetch(key, nil))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def shared
|
|
50
|
+
return nil unless ENV['CONSUMER_MODE']
|
|
51
|
+
|
|
52
|
+
normalize(ENV.fetch('CONSUMER_MODE', nil))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize(mode)
|
|
56
|
+
return nil if mode.nil? || mode.to_s.strip.empty?
|
|
57
|
+
|
|
58
|
+
m = mode.to_s.downcase.to_sym
|
|
59
|
+
return m if VALID_MODES.include?(m)
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, "Invalid consumer mode #{mode.inspect}. Use :pull or :push."
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -64,6 +64,31 @@ module JetstreamBridge
|
|
|
64
64
|
vals.map { |v| to_millis(v, default_unit: default_unit) }
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# Convert duration-like value to seconds (rounding up, min 1s).
|
|
68
|
+
#
|
|
69
|
+
# Retains the nanosecond heuristic used in SubscriptionManager:
|
|
70
|
+
# extremely large integers (>= 1_000_000_000) are treated as nanoseconds
|
|
71
|
+
# when default_unit is :auto.
|
|
72
|
+
def to_seconds(val, default_unit: :auto)
|
|
73
|
+
return nil if val.nil?
|
|
74
|
+
|
|
75
|
+
millis = if val.is_a?(Integer) && default_unit == :auto && val >= 1_000_000_000
|
|
76
|
+
to_millis(val, default_unit: :ns)
|
|
77
|
+
else
|
|
78
|
+
to_millis(val, default_unit: default_unit)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
seconds_from_millis(millis)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Normalize an array of durations into integer seconds.
|
|
85
|
+
def normalize_list_to_seconds(values, default_unit: :auto)
|
|
86
|
+
vals = Array(values)
|
|
87
|
+
return [] if vals.empty?
|
|
88
|
+
|
|
89
|
+
vals.map { |v| to_seconds(v, default_unit: default_unit) }
|
|
90
|
+
end
|
|
91
|
+
|
|
67
92
|
# --- internal helpers ---
|
|
68
93
|
|
|
69
94
|
def int_to_ms(num, default_unit:)
|
|
@@ -100,5 +125,10 @@ module JetstreamBridge
|
|
|
100
125
|
|
|
101
126
|
(num * mult).round
|
|
102
127
|
end
|
|
128
|
+
|
|
129
|
+
def seconds_from_millis(millis)
|
|
130
|
+
# Always round up to avoid zero-second waits when sub-second durations are provided.
|
|
131
|
+
[(millis / 1000.0).ceil, 1].max
|
|
132
|
+
end
|
|
103
133
|
end
|
|
104
134
|
end
|
|
@@ -11,7 +11,7 @@ end
|
|
|
11
11
|
module JetstreamBridge
|
|
12
12
|
if defined?(ActiveRecord::Base)
|
|
13
13
|
class InboxEvent < ActiveRecord::Base
|
|
14
|
-
self.table_name = '
|
|
14
|
+
self.table_name = 'jetstream_bridge_inbox_events'
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
17
|
# Safe column presence check that never boots a connection during class load.
|
|
@@ -11,7 +11,7 @@ end
|
|
|
11
11
|
module JetstreamBridge
|
|
12
12
|
if defined?(ActiveRecord::Base)
|
|
13
13
|
class OutboxEvent < ActiveRecord::Base
|
|
14
|
-
self.table_name = '
|
|
14
|
+
self.table_name = 'jetstream_bridge_outbox_events'
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
17
|
# Safe column presence check that never boots a connection during class load.
|