jetstream_bridge 4.5.0 → 4.5.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 +338 -87
- data/README.md +3 -13
- data/docs/GETTING_STARTED.md +8 -12
- data/docs/PRODUCTION.md +13 -35
- data/docs/RESTRICTED_PERMISSIONS.md +399 -0
- data/docs/TESTING.md +33 -22
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
- data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
- data/lib/jetstream_bridge/core/config.rb +32 -161
- data/lib/jetstream_bridge/core/connection.rb +508 -0
- data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
- data/lib/jetstream_bridge/core.rb +2 -0
- data/lib/jetstream_bridge/models/subject.rb +15 -23
- data/lib/jetstream_bridge/provisioner.rb +67 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
- data/lib/jetstream_bridge/rails/integration.rb +5 -8
- data/lib/jetstream_bridge/rails/railtie.rb +3 -4
- data/lib/jetstream_bridge/tasks/install.rake +17 -1
- data/lib/jetstream_bridge/topology/topology.rb +1 -6
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +345 -202
- metadata +8 -8
- data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
- data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
- data/lib/jetstream_bridge/core/health_checker.rb +0 -184
- data/lib/jetstream_bridge/facade.rb +0 -212
- data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +0 -110
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'oj'
|
|
4
4
|
require 'securerandom'
|
|
5
|
+
require_relative '../core/connection'
|
|
5
6
|
require_relative '../core/duration'
|
|
6
7
|
require_relative '../core/logging'
|
|
7
8
|
require_relative '../core/config'
|
|
@@ -9,7 +10,6 @@ require_relative '../core/model_utils'
|
|
|
9
10
|
require_relative 'message_processor'
|
|
10
11
|
require_relative 'subscription_manager'
|
|
11
12
|
require_relative 'inbox/inbox_processor'
|
|
12
|
-
require_relative 'health_monitor'
|
|
13
13
|
|
|
14
14
|
module JetstreamBridge
|
|
15
15
|
# Subscribes to destination subject and processes messages via a pull durable consumer.
|
|
@@ -48,14 +48,6 @@ module JetstreamBridge
|
|
|
48
48
|
IDLE_SLEEP_SECS = 0.05
|
|
49
49
|
# Maximum sleep duration during idle periods (seconds)
|
|
50
50
|
MAX_IDLE_BACKOFF_SECS = 1.0
|
|
51
|
-
# Maximum number of batches to drain during shutdown
|
|
52
|
-
MAX_DRAIN_BATCHES = 5
|
|
53
|
-
# Drain timeout per batch in seconds
|
|
54
|
-
DRAIN_BATCH_TIMEOUT = 1
|
|
55
|
-
# Minimum reconnect backoff in seconds
|
|
56
|
-
MIN_RECONNECT_BACKOFF = 0.1
|
|
57
|
-
# Maximum reconnect backoff in seconds
|
|
58
|
-
MAX_RECONNECT_BACKOFF = 30.0
|
|
59
51
|
|
|
60
52
|
# Alias middleware classes for easier access
|
|
61
53
|
MiddlewareChain = ConsumerMiddleware::MiddlewareChain
|
|
@@ -72,51 +64,60 @@ module JetstreamBridge
|
|
|
72
64
|
# @return [MiddlewareChain] Middleware chain for processing
|
|
73
65
|
attr_reader :middleware_chain
|
|
74
66
|
|
|
75
|
-
# Initialize a new Consumer instance
|
|
67
|
+
# Initialize a new Consumer instance.
|
|
76
68
|
#
|
|
77
69
|
# @param handler [Proc, #call, nil] Message handler that processes events.
|
|
78
70
|
# Must respond to #call(event) or #call(event, subject, deliveries).
|
|
79
|
-
# @param connection [NATS::JetStream::JS] JetStream connection
|
|
80
|
-
# @param config [Config] Configuration instance
|
|
81
71
|
# @param durable_name [String, nil] Optional durable consumer name override.
|
|
72
|
+
# Defaults to config.durable_name.
|
|
82
73
|
# @param batch_size [Integer, nil] Number of messages to fetch per batch.
|
|
74
|
+
# Defaults to DEFAULT_BATCH_SIZE (25).
|
|
83
75
|
# @yield [event] Optional block as handler. Receives Models::Event object.
|
|
84
76
|
#
|
|
85
|
-
# @raise [ArgumentError] If
|
|
77
|
+
# @raise [ArgumentError] If neither handler nor block provided
|
|
78
|
+
# @raise [ArgumentError] If destination_app not configured
|
|
79
|
+
# @raise [ConnectionError] If unable to connect to NATS
|
|
86
80
|
#
|
|
87
81
|
# @example With proc handler
|
|
88
82
|
# handler = ->(event) { puts "Received: #{event.type}" }
|
|
89
|
-
# consumer = JetstreamBridge::Consumer.new(handler
|
|
83
|
+
# consumer = JetstreamBridge::Consumer.new(handler)
|
|
90
84
|
#
|
|
91
85
|
# @example With block
|
|
92
|
-
# consumer = JetstreamBridge::Consumer.new
|
|
86
|
+
# consumer = JetstreamBridge::Consumer.new do |event|
|
|
93
87
|
# UserEventHandler.process(event)
|
|
94
88
|
# end
|
|
95
89
|
#
|
|
96
|
-
|
|
90
|
+
# @example With custom configuration
|
|
91
|
+
# consumer = JetstreamBridge::Consumer.new(
|
|
92
|
+
# handler,
|
|
93
|
+
# durable_name: "my-consumer",
|
|
94
|
+
# batch_size: 10
|
|
95
|
+
# )
|
|
96
|
+
#
|
|
97
|
+
def initialize(handler = nil, durable_name: nil, batch_size: nil, &block)
|
|
97
98
|
@handler = handler || block
|
|
98
99
|
raise ArgumentError, 'handler or block required' unless @handler
|
|
99
|
-
raise ArgumentError, 'connection is required' unless connection
|
|
100
|
-
raise ArgumentError, 'config is required' unless config
|
|
101
|
-
|
|
102
|
-
@jts = connection
|
|
103
|
-
@config = config
|
|
104
100
|
|
|
105
101
|
@batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
|
|
106
|
-
@durable = durable_name ||
|
|
102
|
+
@durable = durable_name || JetstreamBridge.config.durable_name
|
|
107
103
|
@idle_backoff = IDLE_SLEEP_SECS
|
|
108
104
|
@reconnect_attempts = 0
|
|
109
105
|
@running = true
|
|
110
106
|
@shutdown_requested = false
|
|
107
|
+
@start_time = Time.now
|
|
108
|
+
@iterations = 0
|
|
109
|
+
@last_health_check = Time.now
|
|
110
|
+
# Use existing connection (should already be established)
|
|
111
|
+
@jts = Connection.jetstream
|
|
112
|
+
raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
|
|
111
113
|
|
|
112
114
|
@middleware_chain = MiddlewareChain.new
|
|
113
|
-
@health_monitor = ConsumerHealthMonitor.new(@durable)
|
|
114
115
|
|
|
115
116
|
ensure_destination_app_configured!
|
|
116
117
|
|
|
117
|
-
@sub_mgr = SubscriptionManager.new(@jts, @durable,
|
|
118
|
+
@sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
|
|
118
119
|
@processor = MessageProcessor.new(@jts, @handler, middleware_chain: @middleware_chain)
|
|
119
|
-
@inbox_proc = InboxProcessor.new(@processor) if
|
|
120
|
+
@inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
|
|
120
121
|
|
|
121
122
|
ensure_subscription!
|
|
122
123
|
setup_signal_handlers
|
|
@@ -192,15 +193,17 @@ module JetstreamBridge
|
|
|
192
193
|
#
|
|
193
194
|
def run!
|
|
194
195
|
Logging.info(
|
|
195
|
-
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{
|
|
196
|
+
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{JetstreamBridge.config.destination_subject})…",
|
|
196
197
|
tag: 'JetstreamBridge::Consumer'
|
|
197
198
|
)
|
|
198
199
|
while @running
|
|
199
200
|
processed = process_batch
|
|
200
201
|
idle_sleep(processed)
|
|
201
202
|
|
|
202
|
-
@
|
|
203
|
-
|
|
203
|
+
@iterations += 1
|
|
204
|
+
|
|
205
|
+
# Periodic health checks every 10 minutes (600 seconds)
|
|
206
|
+
perform_health_check_if_due
|
|
204
207
|
end
|
|
205
208
|
|
|
206
209
|
# Drain in-flight messages before exiting
|
|
@@ -241,17 +244,16 @@ module JetstreamBridge
|
|
|
241
244
|
|
|
242
245
|
private
|
|
243
246
|
|
|
244
|
-
def ensure_destination_app_configured!
|
|
245
|
-
return unless @config.destination_app.to_s.empty?
|
|
246
|
-
|
|
247
|
-
raise ArgumentError, 'destination_app must be configured'
|
|
248
|
-
end
|
|
249
|
-
|
|
250
247
|
def ensure_subscription!
|
|
251
|
-
@sub_mgr.ensure_consumer!
|
|
248
|
+
@sub_mgr.ensure_consumer!
|
|
252
249
|
@psub = @sub_mgr.subscribe!
|
|
253
250
|
end
|
|
254
251
|
|
|
252
|
+
def ensure_destination_app_configured!
|
|
253
|
+
# Use subject builder to enforce required components and align with existing validation messages.
|
|
254
|
+
JetstreamBridge.config.destination_subject
|
|
255
|
+
end
|
|
256
|
+
|
|
255
257
|
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
|
256
258
|
def process_batch
|
|
257
259
|
msgs = fetch_messages
|
|
@@ -311,8 +313,10 @@ module JetstreamBridge
|
|
|
311
313
|
end
|
|
312
314
|
|
|
313
315
|
def calculate_reconnect_backoff(attempt)
|
|
314
|
-
# Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to max
|
|
315
|
-
|
|
316
|
+
# Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to 30s max
|
|
317
|
+
base_delay = 0.1
|
|
318
|
+
max_delay = 30.0
|
|
319
|
+
[base_delay * (2**(attempt - 1)), max_delay].min
|
|
316
320
|
end
|
|
317
321
|
|
|
318
322
|
def recoverable_consumer_error?(error)
|
|
@@ -352,13 +356,70 @@ module JetstreamBridge
|
|
|
352
356
|
Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
353
357
|
end
|
|
354
358
|
|
|
359
|
+
def perform_health_check_if_due
|
|
360
|
+
now = Time.now
|
|
361
|
+
time_since_check = now - @last_health_check
|
|
362
|
+
|
|
363
|
+
return unless time_since_check >= 600 # 10 minutes
|
|
364
|
+
|
|
365
|
+
@last_health_check = now
|
|
366
|
+
uptime = now - @start_time
|
|
367
|
+
memory_mb = memory_usage_mb
|
|
368
|
+
|
|
369
|
+
Logging.info(
|
|
370
|
+
"Consumer health: iterations=#{@iterations}, " \
|
|
371
|
+
"memory=#{memory_mb}MB, uptime=#{uptime.round}s",
|
|
372
|
+
tag: 'JetstreamBridge::Consumer'
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Warn if memory usage is high (over 1GB)
|
|
376
|
+
if memory_mb > 1000
|
|
377
|
+
Logging.warn(
|
|
378
|
+
"High memory usage detected: #{memory_mb}MB",
|
|
379
|
+
tag: 'JetstreamBridge::Consumer'
|
|
380
|
+
)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Suggest GC if heap is growing significantly
|
|
384
|
+
suggest_gc_if_needed
|
|
385
|
+
rescue StandardError => e
|
|
386
|
+
Logging.debug(
|
|
387
|
+
"Health check failed: #{e.class} #{e.message}",
|
|
388
|
+
tag: 'JetstreamBridge::Consumer'
|
|
389
|
+
)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def memory_usage_mb
|
|
393
|
+
# Get memory usage from OS (works on Linux/macOS)
|
|
394
|
+
rss_kb = `ps -o rss= -p #{Process.pid}`.to_i
|
|
395
|
+
rss_kb / 1024.0
|
|
396
|
+
rescue StandardError
|
|
397
|
+
0.0
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def suggest_gc_if_needed
|
|
401
|
+
# Suggest GC if heap has many live slots (Ruby-specific optimization)
|
|
402
|
+
return unless defined?(GC) && GC.respond_to?(:stat)
|
|
403
|
+
|
|
404
|
+
stats = GC.stat
|
|
405
|
+
heap_live_slots = stats[:heap_live_slots] || stats['heap_live_slots'] || 0
|
|
406
|
+
|
|
407
|
+
# Suggest GC if we have over 100k live objects
|
|
408
|
+
GC.start if heap_live_slots > 100_000
|
|
409
|
+
rescue StandardError => e
|
|
410
|
+
Logging.debug(
|
|
411
|
+
"GC check failed: #{e.class} #{e.message}",
|
|
412
|
+
tag: 'JetstreamBridge::Consumer'
|
|
413
|
+
)
|
|
414
|
+
end
|
|
415
|
+
|
|
355
416
|
def drain_inflight_messages
|
|
356
417
|
return unless @psub
|
|
357
418
|
|
|
358
419
|
Logging.info('Draining in-flight messages...', tag: 'JetstreamBridge::Consumer')
|
|
359
420
|
# Process any pending messages with a short timeout
|
|
360
|
-
|
|
361
|
-
msgs = @psub.fetch(@batch_size, timeout:
|
|
421
|
+
5.times do
|
|
422
|
+
msgs = @psub.fetch(@batch_size, timeout: 1)
|
|
362
423
|
break if msgs.nil? || msgs.empty?
|
|
363
424
|
|
|
364
425
|
msgs.each { |m| process_one(m) }
|
|
@@ -198,7 +198,7 @@ module JetstreamBridge
|
|
|
198
198
|
|
|
199
199
|
def log_ack(result)
|
|
200
200
|
ctx = result.ctx
|
|
201
|
-
Logging.
|
|
201
|
+
Logging.info(
|
|
202
202
|
"ACK event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} deliveries=#{ctx&.deliveries}",
|
|
203
203
|
tag: 'JetstreamBridge::Consumer'
|
|
204
204
|
)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../core/logging'
|
|
4
4
|
require_relative '../core/duration'
|
|
5
|
+
require_relative '../errors'
|
|
5
6
|
|
|
6
7
|
module JetstreamBridge
|
|
7
8
|
# Encapsulates durable ensure + subscribe for a pull consumer.
|
|
@@ -10,10 +11,7 @@ module JetstreamBridge
|
|
|
10
11
|
@jts = jts
|
|
11
12
|
@durable = durable
|
|
12
13
|
@cfg = cfg
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@desired_cfg = build_consumer_config(@durable, filter_subject)
|
|
16
|
-
@desired_cfg_norm = normalize_consumer_config(@desired_cfg)
|
|
14
|
+
@desired_cfg = build_consumer_config(@durable, filter_subject)
|
|
17
15
|
end
|
|
18
16
|
|
|
19
17
|
def stream_name
|
|
@@ -28,64 +26,69 @@ module JetstreamBridge
|
|
|
28
26
|
@desired_cfg
|
|
29
27
|
end
|
|
30
28
|
|
|
31
|
-
def ensure_consumer!
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
def ensure_consumer!(force: false)
|
|
30
|
+
# Runtime path: never hit JetStream management APIs to avoid admin permissions.
|
|
31
|
+
unless force || @cfg.auto_provision
|
|
32
|
+
log_runtime_skip
|
|
34
33
|
return
|
|
35
34
|
end
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
return create_consumer! unless info
|
|
39
|
-
|
|
40
|
-
have_norm = normalize_consumer_config(info.config)
|
|
41
|
-
if have_norm == @desired_cfg_norm
|
|
42
|
-
log_consumer_ok
|
|
43
|
-
else
|
|
44
|
-
log_consumer_diff(have_norm)
|
|
45
|
-
recreate_consumer!
|
|
46
|
-
end
|
|
36
|
+
create_consumer!
|
|
47
37
|
end
|
|
48
38
|
|
|
49
39
|
# Bind a pull subscriber to the existing durable.
|
|
50
40
|
def subscribe!
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
# Always bypass consumer_info to avoid requiring JetStream API permissions at runtime.
|
|
42
|
+
subscribe_without_verification!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def subscribe_without_verification!
|
|
46
|
+
# Manually create a pull subscription without calling consumer_info
|
|
47
|
+
# This bypasses the permission check in nats-pure's pull_subscribe
|
|
48
|
+
nc = resolve_nc
|
|
49
|
+
|
|
50
|
+
if nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe)
|
|
51
|
+
prefix = @jts.instance_variable_get(:@prefix) || '$JS.API'
|
|
52
|
+
deliver = nc.new_inbox
|
|
53
|
+
sub = nc.subscribe(deliver)
|
|
54
|
+
|
|
55
|
+
# Extend with PullSubscription module to add fetch methods
|
|
56
|
+
sub.extend(NATS::JetStream::PullSubscription)
|
|
57
|
+
|
|
58
|
+
# Set up the JSI (JetStream Info) struct that PullSubscription expects
|
|
59
|
+
# This matches what nats-pure does in pull_subscribe
|
|
60
|
+
subject = "#{prefix}.CONSUMER.MSG.NEXT.#{stream_name}.#{@durable}"
|
|
61
|
+
sub.jsi = NATS::JetStream::JS::Sub.new(
|
|
62
|
+
js: @jts,
|
|
63
|
+
stream: stream_name,
|
|
64
|
+
consumer: @durable,
|
|
65
|
+
nms: subject
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
Logging.info(
|
|
69
|
+
"Created pull subscription without verification for consumer #{@durable} " \
|
|
70
|
+
"(stream=#{stream_name}, filter=#{filter_subject})",
|
|
71
|
+
tag: 'JetstreamBridge::Consumer'
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return sub
|
|
56
75
|
end
|
|
57
76
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
def consumer_info_or_nil
|
|
68
|
-
@jts.consumer_info(stream_name, @durable)
|
|
69
|
-
rescue NATS::JetStream::Error
|
|
70
|
-
nil
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# ---- comparison ----
|
|
74
|
-
|
|
75
|
-
def log_consumer_diff(have_norm)
|
|
76
|
-
want_norm = @desired_cfg_norm
|
|
77
|
-
|
|
78
|
-
diffs = {}
|
|
79
|
-
(have_norm.keys | want_norm.keys).each do |k|
|
|
80
|
-
diffs[k] = { have: have_norm[k], want: want_norm[k] } unless have_norm[k] == want_norm[k]
|
|
77
|
+
# Fallback for environments (mocks/tests) where low-level NATS client is unavailable.
|
|
78
|
+
if @jts.respond_to?(:pull_subscribe)
|
|
79
|
+
Logging.info(
|
|
80
|
+
"Using pull_subscribe fallback for consumer #{@durable} (stream=#{stream_name})",
|
|
81
|
+
tag: 'JetstreamBridge::Consumer'
|
|
82
|
+
)
|
|
83
|
+
return @jts.pull_subscribe(filter_subject, @durable, stream: stream_name)
|
|
81
84
|
end
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
tag: 'JetstreamBridge::Consumer'
|
|
86
|
-
)
|
|
86
|
+
raise JetstreamBridge::ConnectionError,
|
|
87
|
+
'Unable to create subscription without verification: NATS client not available'
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
private
|
|
91
|
+
|
|
89
92
|
def build_consumer_config(durable, filter_subject)
|
|
90
93
|
{
|
|
91
94
|
durable_name: durable,
|
|
@@ -93,36 +96,12 @@ module JetstreamBridge
|
|
|
93
96
|
ack_policy: 'explicit',
|
|
94
97
|
deliver_policy: 'all',
|
|
95
98
|
max_deliver: JetstreamBridge.config.max_deliver,
|
|
96
|
-
# JetStream expects
|
|
97
|
-
ack_wait:
|
|
98
|
-
backoff: Array(JetstreamBridge.config.backoff).map { |d|
|
|
99
|
+
# JetStream expects seconds (the client multiplies by nanoseconds).
|
|
100
|
+
ack_wait: duration_to_seconds(JetstreamBridge.config.ack_wait),
|
|
101
|
+
backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_seconds(d) }
|
|
99
102
|
}
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
# Normalize both server-returned config objects and our desired hash
|
|
103
|
-
# into a common hash with consistent units/types for accurate comparison.
|
|
104
|
-
def normalize_consumer_config(cfg)
|
|
105
|
-
{
|
|
106
|
-
filter_subject: sval(cfg, :filter_subject), # string
|
|
107
|
-
ack_policy: sval(cfg, :ack_policy), # string
|
|
108
|
-
deliver_policy: sval(cfg, :deliver_policy), # string
|
|
109
|
-
max_deliver: ival(cfg, :max_deliver), # integer
|
|
110
|
-
ack_wait_nanos: nanos(cfg, :ack_wait),
|
|
111
|
-
backoff_nanos: nanos_arr(cfg, :backoff)
|
|
112
|
-
}
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# ---- lifecycle helpers ----
|
|
116
|
-
|
|
117
|
-
def recreate_consumer!
|
|
118
|
-
Logging.warn(
|
|
119
|
-
"Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
|
|
120
|
-
tag: 'JetstreamBridge::Consumer'
|
|
121
|
-
)
|
|
122
|
-
safe_delete_consumer
|
|
123
|
-
create_consumer!
|
|
124
|
-
end
|
|
125
|
-
|
|
126
105
|
def create_consumer!
|
|
127
106
|
@jts.add_consumer(stream_name, **desired_consumer_cfg)
|
|
128
107
|
Logging.info(
|
|
@@ -131,22 +110,6 @@ module JetstreamBridge
|
|
|
131
110
|
)
|
|
132
111
|
end
|
|
133
112
|
|
|
134
|
-
def log_consumer_ok
|
|
135
|
-
Logging.info(
|
|
136
|
-
"Consumer #{@durable} exists with desired config.",
|
|
137
|
-
tag: 'JetstreamBridge::Consumer'
|
|
138
|
-
)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def safe_delete_consumer
|
|
142
|
-
@jts.delete_consumer(stream_name, @durable)
|
|
143
|
-
rescue NATS::JetStream::Error => e
|
|
144
|
-
Logging.warn(
|
|
145
|
-
"Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
|
|
146
|
-
tag: 'JetstreamBridge::Consumer'
|
|
147
|
-
)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
113
|
# ---- cfg access/normalization (struct-like or hash-like) ----
|
|
151
114
|
|
|
152
115
|
def get(cfg, key)
|
|
@@ -164,58 +127,71 @@ module JetstreamBridge
|
|
|
164
127
|
v.to_i
|
|
165
128
|
end
|
|
166
129
|
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
130
|
+
# Normalize duration-like field to **milliseconds** (Integer).
|
|
131
|
+
# Accepts:
|
|
132
|
+
# - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
|
|
133
|
+
# - Integers/Floats:
|
|
134
|
+
# * Server may return large integers in **nanoseconds** → detect and convert.
|
|
135
|
+
# * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
|
|
136
|
+
def d_secs(cfg, key)
|
|
170
137
|
raw = get(cfg, key)
|
|
171
|
-
|
|
138
|
+
duration_to_seconds(raw)
|
|
172
139
|
end
|
|
173
140
|
|
|
174
|
-
|
|
141
|
+
# Normalize array of durations to integer milliseconds.
|
|
142
|
+
def darr_secs(cfg, key)
|
|
175
143
|
raw = get(cfg, key)
|
|
176
|
-
Array(raw).map { |d|
|
|
144
|
+
Array(raw).map { |d| duration_to_seconds(d) }
|
|
177
145
|
end
|
|
178
146
|
|
|
179
|
-
|
|
147
|
+
# ---- duration coercion ----
|
|
148
|
+
|
|
149
|
+
def duration_to_seconds(val)
|
|
180
150
|
return nil if val.nil?
|
|
181
151
|
|
|
182
152
|
case val
|
|
183
153
|
when Integer
|
|
184
|
-
# Heuristic: extremely large integers are likely
|
|
185
|
-
|
|
154
|
+
# Heuristic: extremely large integers are likely **nanoseconds** from server
|
|
155
|
+
# (e.g., 30s => 30_000_000_000 ns). Convert ns → seconds.
|
|
156
|
+
return (val / 1_000_000_000.0).round if val >= 1_000_000_000
|
|
186
157
|
|
|
158
|
+
# otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
|
|
187
159
|
millis = Duration.to_millis(val, default_unit: :auto)
|
|
188
|
-
(millis
|
|
160
|
+
seconds_from_millis(millis)
|
|
189
161
|
when Float
|
|
190
162
|
millis = Duration.to_millis(val, default_unit: :auto)
|
|
191
|
-
(millis
|
|
163
|
+
seconds_from_millis(millis)
|
|
192
164
|
when String
|
|
193
|
-
|
|
194
|
-
|
|
165
|
+
# Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
|
|
166
|
+
millis = Duration.to_millis(val) # default_unit ignored when unit given
|
|
167
|
+
seconds_from_millis(millis)
|
|
195
168
|
else
|
|
196
|
-
return
|
|
169
|
+
return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
|
|
197
170
|
|
|
198
171
|
raise ArgumentError, "invalid duration: #{val.inspect}"
|
|
199
172
|
end
|
|
200
173
|
end
|
|
201
174
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
175
|
+
def seconds_from_millis(millis)
|
|
176
|
+
# Always round up to avoid zero-second waits when sub-second durations are provided.
|
|
177
|
+
[(millis / 1000.0).ceil, 1].max
|
|
178
|
+
end
|
|
205
179
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
180
|
+
def log_runtime_skip
|
|
181
|
+
Logging.info(
|
|
182
|
+
"Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
|
|
183
|
+
'Ensure it is pre-created via provisioning.',
|
|
184
|
+
tag: 'JetstreamBridge::Consumer'
|
|
185
|
+
)
|
|
186
|
+
end
|
|
210
187
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
end
|
|
188
|
+
def resolve_nc
|
|
189
|
+
return @jts.nc if @jts.respond_to?(:nc)
|
|
190
|
+
return @jts.instance_variable_get(:@nc) if @jts.instance_variable_defined?(:@nc)
|
|
191
|
+
|
|
192
|
+
return @cfg.mock_nats_client if @cfg.respond_to?(:mock_nats_client) && @cfg.mock_nats_client
|
|
193
|
+
|
|
194
|
+
nil
|
|
219
195
|
end
|
|
220
196
|
end
|
|
221
197
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
return skipped_stream_info unless config.auto_provision
|
|
41
|
+
|
|
42
|
+
# Ensure we have an active connection before querying stream info
|
|
43
|
+
connect_if_needed!
|
|
44
|
+
|
|
45
|
+
jts = Connection.jetstream
|
|
46
|
+
raise ConnectionNotEstablishedError, 'NATS connection not established' unless jts
|
|
47
|
+
|
|
48
|
+
stream_info_payload(jts.stream_info(config.stream_name))
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
stream_error_payload(e)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def measure_nats_rtt
|
|
54
|
+
nc = Connection.nc
|
|
55
|
+
return nil unless nc
|
|
56
|
+
|
|
57
|
+
# Prefer native RTT API when available (e.g., new NATS clients)
|
|
58
|
+
if nc.respond_to?(:rtt)
|
|
59
|
+
rtt_value = normalize_ms(nc.rtt)
|
|
60
|
+
return rtt_value if rtt_value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Fallback for clients without #rtt (nats-pure): measure ping/pong via flush
|
|
64
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
65
|
+
nc.flush(1)
|
|
66
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
67
|
+
normalize_ms(duration)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Logging.warn(
|
|
70
|
+
"Failed to measure NATS RTT: #{e.class} #{e.message}",
|
|
71
|
+
tag: 'JetstreamBridge'
|
|
72
|
+
)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_ms(value)
|
|
77
|
+
return nil if value.nil?
|
|
78
|
+
return nil unless value.respond_to?(:to_f)
|
|
79
|
+
|
|
80
|
+
numeric = value.to_f
|
|
81
|
+
# Heuristic: sub-1 values are likely seconds; convert them to ms for reporting
|
|
82
|
+
ms = numeric < 1 ? numeric * 1000 : numeric
|
|
83
|
+
ms.round(2)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def assign_config_option!(cfg, key, val)
|
|
87
|
+
setter = :"#{key}="
|
|
88
|
+
raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
|
|
89
|
+
|
|
90
|
+
cfg.public_send(setter, val)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stream_info_payload(info)
|
|
94
|
+
config_data = info.config
|
|
95
|
+
state_data = info.state
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
exists: true,
|
|
99
|
+
name: config.stream_name,
|
|
100
|
+
subjects: extract_field(config_data, :subjects),
|
|
101
|
+
messages: extract_field(state_data, :messages)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extract_field(data, key)
|
|
106
|
+
data.respond_to?(key) ? data.public_send(key) : data[key]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stream_error_payload(error)
|
|
110
|
+
{
|
|
111
|
+
exists: false,
|
|
112
|
+
name: config.stream_name,
|
|
113
|
+
error: "#{error.class}: #{error.message}"
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def skipped_stream_info
|
|
118
|
+
{
|
|
119
|
+
exists: nil,
|
|
120
|
+
name: config.stream_name,
|
|
121
|
+
skipped: true,
|
|
122
|
+
reason: 'auto_provision=false (skip $JS.API.STREAM.INFO)'
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|