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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'core/logging'
|
|
4
|
+
require_relative 'core/config'
|
|
5
|
+
require_relative 'core/consumer_mode_resolver'
|
|
6
|
+
require_relative 'config_helpers/lifecycle'
|
|
7
|
+
|
|
8
|
+
module JetstreamBridge
|
|
9
|
+
# Convenience helpers to keep example configuration lean and consistent.
|
|
10
|
+
module ConfigHelpers
|
|
11
|
+
DEFAULT_STREAM = 'sync-stream'
|
|
12
|
+
DEFAULT_BACKOFF = %w[1s 5s 15s 30s 60s].freeze
|
|
13
|
+
DEFAULT_ACK_WAIT = '30s'
|
|
14
|
+
DEFAULT_MAX_DELIVER = 5
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Configure a bidirectional bridge with sensible defaults.
|
|
19
|
+
#
|
|
20
|
+
# @param app_name [String] Name of the local app (publisher + consumer)
|
|
21
|
+
# @param destination_app [String] Remote app to sync with
|
|
22
|
+
# @param mode [Symbol] :non_restrictive (auto provision) or :restrictive
|
|
23
|
+
# @param stream_name [String] JetStream stream name
|
|
24
|
+
# @param nats_url [String] NATS connection URL(s)
|
|
25
|
+
# @param use_outbox [Boolean] Enable transactional outbox pattern
|
|
26
|
+
# @param use_inbox [Boolean] Enable idempotent inbox pattern
|
|
27
|
+
# @param logger [Logger,nil] Logger to attach to configuration
|
|
28
|
+
# @param overrides [Hash] Additional config overrides applied verbatim
|
|
29
|
+
#
|
|
30
|
+
# @yield [config] Optional block for further customization
|
|
31
|
+
#
|
|
32
|
+
# @return [JetstreamBridge::Config]
|
|
33
|
+
def configure_bidirectional(
|
|
34
|
+
app_name:,
|
|
35
|
+
destination_app:,
|
|
36
|
+
mode: :non_restrictive,
|
|
37
|
+
stream_name: DEFAULT_STREAM,
|
|
38
|
+
nats_url: ENV.fetch('NATS_URL', 'nats://nats:4222'),
|
|
39
|
+
use_outbox: true,
|
|
40
|
+
use_inbox: true,
|
|
41
|
+
logger: nil,
|
|
42
|
+
**overrides
|
|
43
|
+
)
|
|
44
|
+
JetstreamBridge.configure do |config|
|
|
45
|
+
apply_base_settings(config, app_name, destination_app, stream_name, nats_url, use_outbox, use_inbox, mode,
|
|
46
|
+
overrides)
|
|
47
|
+
apply_reliability_defaults(config, overrides)
|
|
48
|
+
config.logger = logger if logger
|
|
49
|
+
apply_overrides(config, overrides)
|
|
50
|
+
yield(config) if block_given?
|
|
51
|
+
|
|
52
|
+
config
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Wire JetstreamBridge lifecycle into Rails boot/shutdown.
|
|
57
|
+
def setup_rails_lifecycle(logger: nil, rails_app: nil)
|
|
58
|
+
Lifecycle.setup(logger: logger, rails_app: rails_app)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def restrictive?(mode)
|
|
62
|
+
mode.to_sym == :restrictive
|
|
63
|
+
end
|
|
64
|
+
private_class_method :restrictive?
|
|
65
|
+
|
|
66
|
+
def apply_base_settings(config, app_name, destination_app, stream_name, nats_url, use_outbox, use_inbox, mode,
|
|
67
|
+
overrides)
|
|
68
|
+
config.nats_urls = nats_url
|
|
69
|
+
config.app_name = app_name
|
|
70
|
+
config.destination_app = destination_app
|
|
71
|
+
config.stream_name = stream_name
|
|
72
|
+
config.auto_provision = !restrictive?(mode)
|
|
73
|
+
config.use_outbox = use_outbox
|
|
74
|
+
config.use_inbox = use_inbox
|
|
75
|
+
config.consumer_mode = resolve_consumer_mode(app_name, overrides)
|
|
76
|
+
end
|
|
77
|
+
private_class_method :apply_base_settings
|
|
78
|
+
|
|
79
|
+
# Resolve consumer_mode with priority:
|
|
80
|
+
# 1) explicit override passed to configure_bidirectional
|
|
81
|
+
# 2) per-app env via CONSUMER_MODES map or CONSUMER_MODE_<APP_NAME>
|
|
82
|
+
# 3) shared env CONSUMER_MODE
|
|
83
|
+
# 4) existing config value or :pull
|
|
84
|
+
def resolve_consumer_mode(app_name, overrides)
|
|
85
|
+
explicit = overrides[:consumer_mode] if overrides.key?(:consumer_mode)
|
|
86
|
+
config_default = begin
|
|
87
|
+
JetstreamBridge.config&.consumer_mode
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
ConsumerModeResolver.resolve(
|
|
93
|
+
app_name: app_name,
|
|
94
|
+
override: explicit,
|
|
95
|
+
fallback: config_default || :pull
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
private_class_method :resolve_consumer_mode
|
|
99
|
+
|
|
100
|
+
def apply_reliability_defaults(config, overrides)
|
|
101
|
+
config.max_deliver = overrides.fetch(:max_deliver, DEFAULT_MAX_DELIVER)
|
|
102
|
+
config.ack_wait = overrides.fetch(:ack_wait, DEFAULT_ACK_WAIT)
|
|
103
|
+
config.backoff = overrides.fetch(:backoff, DEFAULT_BACKOFF)
|
|
104
|
+
end
|
|
105
|
+
private_class_method :apply_reliability_defaults
|
|
106
|
+
|
|
107
|
+
def apply_overrides(config, overrides)
|
|
108
|
+
ignored = [:max_deliver, :ack_wait, :backoff, :consumer_mode]
|
|
109
|
+
overrides.each do |key, value|
|
|
110
|
+
next if ignored.include?(key)
|
|
111
|
+
|
|
112
|
+
setter = "#{key}="
|
|
113
|
+
config.public_send(setter, value) if config.respond_to?(setter)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :apply_overrides
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -9,6 +9,7 @@ require_relative '../core/config'
|
|
|
9
9
|
require_relative '../core/model_utils'
|
|
10
10
|
require_relative 'message_processor'
|
|
11
11
|
require_relative 'subscription_manager'
|
|
12
|
+
require_relative 'consumer_state'
|
|
12
13
|
require_relative 'inbox/inbox_processor'
|
|
13
14
|
|
|
14
15
|
module JetstreamBridge
|
|
@@ -57,12 +58,91 @@ module JetstreamBridge
|
|
|
57
58
|
TracingMiddleware = ConsumerMiddleware::TracingMiddleware
|
|
58
59
|
TimeoutMiddleware = ConsumerMiddleware::TimeoutMiddleware
|
|
59
60
|
|
|
61
|
+
class << self
|
|
62
|
+
def register_consumer_for_signals(consumer)
|
|
63
|
+
signal_registry_mutex.synchronize do
|
|
64
|
+
signal_consumers << consumer
|
|
65
|
+
install_signal_handlers_once
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unregister_consumer_for_signals(consumer)
|
|
70
|
+
signal_registry_mutex.synchronize { signal_consumers.delete(consumer) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_signal_handlers!
|
|
74
|
+
signal_registry_mutex.synchronize { signal_consumers.clear }
|
|
75
|
+
@signal_handlers_installed = false
|
|
76
|
+
@previous_signal_handlers = {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def signal_consumers
|
|
82
|
+
@signal_consumers ||= []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def signal_registry_mutex
|
|
86
|
+
@signal_registry_mutex ||= Mutex.new
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def install_signal_handlers_once
|
|
90
|
+
return if @signal_handlers_installed
|
|
91
|
+
|
|
92
|
+
%w[INT TERM].each { |sig| install_signal_handler(sig) }
|
|
93
|
+
@signal_handlers_installed = true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def install_signal_handler(sig)
|
|
97
|
+
previous = nil
|
|
98
|
+
handler = nil
|
|
99
|
+
handler = proc do
|
|
100
|
+
broadcast_signal(sig)
|
|
101
|
+
invoke_previous_handler(previous, sig, handler)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
# Trap contexts must stay minimal; swallow any unexpected errors
|
|
104
|
+
end
|
|
105
|
+
previous = Signal.trap(sig, &handler)
|
|
106
|
+
previous_signal_handlers[sig] = previous
|
|
107
|
+
rescue ArgumentError => e
|
|
108
|
+
Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def broadcast_signal(sig)
|
|
112
|
+
consumers = nil
|
|
113
|
+
signal_registry_mutex.synchronize { consumers = signal_consumers.dup }
|
|
114
|
+
consumers.each do |consumer|
|
|
115
|
+
next unless consumer.respond_to?(:lifecycle_state)
|
|
116
|
+
|
|
117
|
+
consumer.lifecycle_state.signal!(sig)
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError
|
|
120
|
+
# Trap safety: never raise
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def invoke_previous_handler(previous, sig, current_handler = nil)
|
|
124
|
+
return if previous.nil? || previous == 'DEFAULT' || previous == 'SYSTEM_DEFAULT'
|
|
125
|
+
return if previous == 'IGNORE'
|
|
126
|
+
return if current_handler && previous.equal?(current_handler)
|
|
127
|
+
|
|
128
|
+
previous.call(sig) if previous.respond_to?(:call)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
# Never bubble from trap context
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def previous_signal_handlers
|
|
134
|
+
@previous_signal_handlers ||= {}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
60
138
|
# @return [String] Durable consumer name
|
|
61
139
|
attr_reader :durable
|
|
62
140
|
# @return [Integer] Batch size for message fetching
|
|
63
141
|
attr_reader :batch_size
|
|
64
142
|
# @return [MiddlewareChain] Middleware chain for processing
|
|
65
143
|
attr_reader :middleware_chain
|
|
144
|
+
# Expose grouped state objects for observability/testing
|
|
145
|
+
attr_reader :processing_state, :lifecycle_state, :connection_state
|
|
66
146
|
|
|
67
147
|
# Initialize a new Consumer instance.
|
|
68
148
|
#
|
|
@@ -100,15 +180,9 @@ module JetstreamBridge
|
|
|
100
180
|
|
|
101
181
|
@batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
|
|
102
182
|
@durable = durable_name || JetstreamBridge.config.durable_name
|
|
103
|
-
@
|
|
104
|
-
@
|
|
105
|
-
@
|
|
106
|
-
@shutdown_requested = false
|
|
107
|
-
@signal_received = nil
|
|
108
|
-
@signal_logged = false
|
|
109
|
-
@start_time = Time.now
|
|
110
|
-
@iterations = 0
|
|
111
|
-
@last_health_check = Time.now
|
|
183
|
+
@processing_state = ProcessingState.new(idle_backoff: IDLE_SLEEP_SECS)
|
|
184
|
+
@lifecycle_state = LifecycleState.new
|
|
185
|
+
@connection_state = ConnectionState.new
|
|
112
186
|
# Use existing connection (should already be established)
|
|
113
187
|
@jts = Connection.jetstream
|
|
114
188
|
raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
|
|
@@ -198,24 +272,33 @@ module JetstreamBridge
|
|
|
198
272
|
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{JetstreamBridge.config.destination_subject})…",
|
|
199
273
|
tag: 'JetstreamBridge::Consumer'
|
|
200
274
|
)
|
|
201
|
-
while @running
|
|
275
|
+
while @lifecycle_state.running
|
|
202
276
|
# Check if signal was received and log it (safe from main loop)
|
|
203
|
-
if @signal_received && !@signal_logged
|
|
204
|
-
Logging.info("Received #{@signal_received}, stopping consumer...",
|
|
205
|
-
|
|
277
|
+
if @lifecycle_state.signal_received && !@lifecycle_state.signal_logged
|
|
278
|
+
Logging.info("Received #{@lifecycle_state.signal_received}, stopping consumer...",
|
|
279
|
+
tag: 'JetstreamBridge::Consumer')
|
|
280
|
+
@lifecycle_state.signal_logged = true
|
|
206
281
|
end
|
|
207
282
|
|
|
283
|
+
Logging.debug(
|
|
284
|
+
"Fetching messages (iteration=#{@processing_state.iterations}, batch_size=#{@batch_size})...",
|
|
285
|
+
tag: 'JetstreamBridge::Consumer'
|
|
286
|
+
)
|
|
208
287
|
processed = process_batch
|
|
288
|
+
Logging.debug(
|
|
289
|
+
"Processed #{processed} messages",
|
|
290
|
+
tag: 'JetstreamBridge::Consumer'
|
|
291
|
+
)
|
|
209
292
|
idle_sleep(processed)
|
|
210
293
|
|
|
211
|
-
@iterations += 1
|
|
294
|
+
@processing_state.iterations += 1
|
|
212
295
|
|
|
213
296
|
# Periodic health checks every 10 minutes (600 seconds)
|
|
214
297
|
perform_health_check_if_due
|
|
215
298
|
end
|
|
216
299
|
|
|
217
300
|
# Drain in-flight messages before exiting
|
|
218
|
-
drain_inflight_messages if @shutdown_requested
|
|
301
|
+
drain_inflight_messages if @lifecycle_state.shutdown_requested
|
|
219
302
|
Logging.info("Consumer #{@durable} stopped gracefully", tag: 'JetstreamBridge::Consumer')
|
|
220
303
|
end
|
|
221
304
|
|
|
@@ -245,8 +328,9 @@ module JetstreamBridge
|
|
|
245
328
|
# consumer.stop! # Stop after 10 seconds
|
|
246
329
|
#
|
|
247
330
|
def stop!
|
|
248
|
-
@
|
|
249
|
-
|
|
331
|
+
@lifecycle_state.stop!
|
|
332
|
+
# Allow other consumers to continue receiving signals without stale references
|
|
333
|
+
self.class.unregister_consumer_for_signals(self)
|
|
250
334
|
Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
|
|
251
335
|
end
|
|
252
336
|
|
|
@@ -264,16 +348,21 @@ module JetstreamBridge
|
|
|
264
348
|
|
|
265
349
|
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
|
266
350
|
def process_batch
|
|
351
|
+
Logging.debug('Calling fetch_messages...', tag: 'JetstreamBridge::Consumer')
|
|
267
352
|
msgs = fetch_messages
|
|
353
|
+
Logging.debug("Fetched #{msgs&.size || 0} messages", tag: 'JetstreamBridge::Consumer')
|
|
268
354
|
return 0 if msgs.nil? || msgs.empty?
|
|
269
355
|
|
|
270
356
|
msgs.sum { |m| process_one(m) }
|
|
271
|
-
rescue NATS::Timeout, NATS::IO::Timeout
|
|
357
|
+
rescue NATS::Timeout, NATS::IO::Timeout => e
|
|
358
|
+
Logging.debug("Fetch timeout: #{e.class}", tag: 'JetstreamBridge::Consumer')
|
|
272
359
|
0
|
|
273
360
|
rescue NATS::JetStream::Error => e
|
|
361
|
+
Logging.error("JetStream error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
274
362
|
handle_js_error(e)
|
|
275
363
|
rescue StandardError => e
|
|
276
364
|
Logging.error("Unexpected process_batch error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
365
|
+
Logging.error("Backtrace: #{e.backtrace.first(5).join("\n")}", tag: 'JetstreamBridge::Consumer')
|
|
277
366
|
0
|
|
278
367
|
end
|
|
279
368
|
|
|
@@ -288,7 +377,17 @@ module JetstreamBridge
|
|
|
288
377
|
end
|
|
289
378
|
|
|
290
379
|
def fetch_messages_pull
|
|
291
|
-
|
|
380
|
+
Logging.debug(
|
|
381
|
+
"fetch_messages_pull called (@psub=#{@psub.class}, batch=#{@batch_size}, timeout=#{FETCH_TIMEOUT_SECS})",
|
|
382
|
+
tag: 'JetstreamBridge::Consumer'
|
|
383
|
+
)
|
|
384
|
+
result = @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
|
|
385
|
+
Logging.debug("fetch returned #{result&.size || 0} messages", tag: 'JetstreamBridge::Consumer')
|
|
386
|
+
result
|
|
387
|
+
rescue StandardError => e
|
|
388
|
+
Logging.error("fetch_messages_pull error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
389
|
+
Logging.error("Backtrace: #{e.backtrace.first(5).join("\n")}", tag: 'JetstreamBridge::Consumer')
|
|
390
|
+
raise
|
|
292
391
|
end
|
|
293
392
|
|
|
294
393
|
def fetch_messages_push
|
|
@@ -321,11 +420,11 @@ module JetstreamBridge
|
|
|
321
420
|
def handle_js_error(error)
|
|
322
421
|
if recoverable_consumer_error?(error)
|
|
323
422
|
# Increment reconnect attempts and calculate exponential backoff
|
|
324
|
-
@reconnect_attempts += 1
|
|
325
|
-
backoff_secs = calculate_reconnect_backoff(@reconnect_attempts)
|
|
423
|
+
@connection_state.reconnect_attempts += 1
|
|
424
|
+
backoff_secs = calculate_reconnect_backoff(@connection_state.reconnect_attempts)
|
|
326
425
|
|
|
327
426
|
Logging.warn(
|
|
328
|
-
"Recovering subscription after error (attempt #{@reconnect_attempts}): " \
|
|
427
|
+
"Recovering subscription after error (attempt #{@connection_state.reconnect_attempts}): " \
|
|
329
428
|
"#{error.class} #{error.message}, waiting #{backoff_secs}s",
|
|
330
429
|
tag: 'JetstreamBridge::Consumer'
|
|
331
430
|
)
|
|
@@ -334,7 +433,7 @@ module JetstreamBridge
|
|
|
334
433
|
ensure_subscription!
|
|
335
434
|
|
|
336
435
|
# Reset counter on successful reconnection
|
|
337
|
-
@reconnect_attempts = 0
|
|
436
|
+
@connection_state.reconnect_attempts = 0
|
|
338
437
|
else
|
|
339
438
|
Logging.error("Fetch failed (non-recoverable): #{error.class} #{error.message}", tag: 'JetstreamBridge::Consumer')
|
|
340
439
|
end
|
|
@@ -366,40 +465,31 @@ module JetstreamBridge
|
|
|
366
465
|
def idle_sleep(processed)
|
|
367
466
|
if processed.zero?
|
|
368
467
|
# exponential-ish backoff with a tiny jitter to avoid sync across workers
|
|
369
|
-
@idle_backoff = [@idle_backoff * 1.5, MAX_IDLE_BACKOFF_SECS].min
|
|
370
|
-
sleep(@idle_backoff + (rand * 0.01))
|
|
468
|
+
@processing_state.idle_backoff = [@processing_state.idle_backoff * 1.5, MAX_IDLE_BACKOFF_SECS].min
|
|
469
|
+
sleep(@processing_state.idle_backoff + (rand * 0.01))
|
|
371
470
|
else
|
|
372
|
-
@idle_backoff = IDLE_SLEEP_SECS
|
|
471
|
+
@processing_state.idle_backoff = IDLE_SLEEP_SECS
|
|
373
472
|
end
|
|
374
473
|
end
|
|
375
474
|
|
|
376
475
|
def setup_signal_handlers
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
# CRITICAL: Only set flags in trap context, no I/O or mutex operations
|
|
380
|
-
# Logging and other operations are unsafe from signal handlers
|
|
381
|
-
@signal_received = sig
|
|
382
|
-
@running = false
|
|
383
|
-
@shutdown_requested = true
|
|
384
|
-
end
|
|
385
|
-
end
|
|
386
|
-
rescue ArgumentError => e
|
|
387
|
-
# Signal handlers may not be available in all environments (e.g., threads)
|
|
476
|
+
self.class.register_consumer_for_signals(self)
|
|
477
|
+
rescue StandardError => e
|
|
388
478
|
Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
389
479
|
end
|
|
390
480
|
|
|
391
481
|
def perform_health_check_if_due
|
|
392
482
|
now = Time.now
|
|
393
|
-
time_since_check = now - @last_health_check
|
|
483
|
+
time_since_check = now - @connection_state.last_health_check
|
|
394
484
|
|
|
395
485
|
return unless time_since_check >= 600 # 10 minutes
|
|
396
486
|
|
|
397
|
-
@
|
|
398
|
-
uptime = now
|
|
487
|
+
@connection_state.mark_health_check(now)
|
|
488
|
+
uptime = @lifecycle_state.uptime(now)
|
|
399
489
|
memory_mb = memory_usage_mb
|
|
400
490
|
|
|
401
491
|
Logging.info(
|
|
402
|
-
"Consumer health: iterations=#{@iterations}, " \
|
|
492
|
+
"Consumer health: iterations=#{@processing_state.iterations}, " \
|
|
403
493
|
"memory=#{memory_mb}MB, uptime=#{uptime.round}s",
|
|
404
494
|
tag: 'JetstreamBridge::Consumer'
|
|
405
495
|
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
class Consumer
|
|
5
|
+
# Tracks processing counters and backoff state.
|
|
6
|
+
class ProcessingState
|
|
7
|
+
attr_accessor :idle_backoff, :iterations
|
|
8
|
+
|
|
9
|
+
def initialize(idle_backoff:, iterations: 0)
|
|
10
|
+
@idle_backoff = idle_backoff
|
|
11
|
+
@iterations = iterations
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Tracks lifecycle flags and timing for the consumer.
|
|
16
|
+
class LifecycleState
|
|
17
|
+
attr_accessor :running, :shutdown_requested, :signal_received, :signal_logged
|
|
18
|
+
attr_reader :start_time
|
|
19
|
+
|
|
20
|
+
def initialize(start_time: Time.now)
|
|
21
|
+
@running = true
|
|
22
|
+
@shutdown_requested = false
|
|
23
|
+
@signal_received = nil
|
|
24
|
+
@signal_logged = false
|
|
25
|
+
@start_time = start_time
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop!
|
|
29
|
+
@shutdown_requested = true
|
|
30
|
+
@running = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def signal!(sig)
|
|
34
|
+
@signal_received = sig
|
|
35
|
+
stop!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def uptime(now = Time.now)
|
|
39
|
+
now - @start_time
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Tracks reconnection attempts and health check timing.
|
|
44
|
+
class ConnectionState
|
|
45
|
+
attr_accessor :reconnect_attempts
|
|
46
|
+
attr_reader :last_health_check
|
|
47
|
+
|
|
48
|
+
def initialize(now: Time.now)
|
|
49
|
+
@reconnect_attempts = 0
|
|
50
|
+
@last_health_check = now
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mark_health_check(now = Time.now)
|
|
54
|
+
@last_health_check = now
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -30,6 +30,9 @@ module JetstreamBridge
|
|
|
30
30
|
ActiveRecord::Base.transaction do
|
|
31
31
|
attrs = {
|
|
32
32
|
event_id: msg.event_id,
|
|
33
|
+
event_type: msg.body['type'] || msg.body['event_type'],
|
|
34
|
+
resource_type: msg.body['resource_type'],
|
|
35
|
+
resource_id: msg.body['resource_id'],
|
|
33
36
|
subject: msg.subject,
|
|
34
37
|
payload: ModelUtils.json_dump(msg.body_for_store),
|
|
35
38
|
headers: ModelUtils.json_dump(msg.headers),
|
|
@@ -37,10 +40,14 @@ module JetstreamBridge
|
|
|
37
40
|
stream_seq: msg.seq,
|
|
38
41
|
deliveries: msg.deliveries,
|
|
39
42
|
status: 'processing',
|
|
40
|
-
|
|
43
|
+
error_message: nil, # Clear any previous error
|
|
44
|
+
last_error: nil, # Legacy field (for backwards compatibility)
|
|
45
|
+
processing_attempts: (record.respond_to?(:processing_attempts) ? (record.processing_attempts || 0) + 1 : nil),
|
|
41
46
|
received_at: record.respond_to?(:received_at) ? (record.received_at || msg.now) : nil,
|
|
42
47
|
updated_at: record.respond_to?(:updated_at) ? msg.now : nil
|
|
43
48
|
}
|
|
49
|
+
# Some schemas capture the producing app
|
|
50
|
+
attrs[:source_app] = msg.body['producer'] || msg.headers['producer'] if record.respond_to?(:source_app=)
|
|
44
51
|
ModelUtils.assign_known_attrs(record, attrs)
|
|
45
52
|
record.save!
|
|
46
53
|
end
|
|
@@ -64,9 +71,12 @@ module JetstreamBridge
|
|
|
64
71
|
|
|
65
72
|
ActiveRecord::Base.transaction do
|
|
66
73
|
now = Time.now.utc
|
|
74
|
+
error_msg = "#{error.class}: #{error.message}"
|
|
67
75
|
attrs = {
|
|
68
76
|
status: 'failed',
|
|
69
|
-
|
|
77
|
+
error_message: error_msg, # Standard field name
|
|
78
|
+
last_error: error_msg, # Legacy field (for backwards compatibility)
|
|
79
|
+
failed_at: record.respond_to?(:failed_at) ? now : nil,
|
|
70
80
|
updated_at: record.respond_to?(:updated_at) ? now : nil
|
|
71
81
|
}
|
|
72
82
|
ModelUtils.assign_known_attrs(record, attrs)
|
|
@@ -22,8 +22,8 @@ module JetstreamBridge
|
|
|
22
22
|
sub.instance_variable_set(:@_jsb_deliver, deliver)
|
|
23
23
|
sub.instance_variable_set(:@_jsb_next_subject, "#{prefix}.CONSUMER.MSG.NEXT.#{@stream_name}.#{@durable}")
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
apply_pull_subscription_extensions(sub)
|
|
26
|
+
attach_js_subscription_metadata(sub)
|
|
27
27
|
|
|
28
28
|
Logging.info(
|
|
29
29
|
"Created pull subscription without verification for consumer #{@durable} " \
|
|
@@ -36,7 +36,7 @@ module JetstreamBridge
|
|
|
36
36
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
|
-
def
|
|
39
|
+
def apply_pull_subscription_extensions(sub)
|
|
40
40
|
pull_mod = begin
|
|
41
41
|
NATS::JetStream.const_get(:PullSubscription)
|
|
42
42
|
rescue NameError
|
|
@@ -44,10 +44,10 @@ module JetstreamBridge
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
sub.extend(pull_mod) if pull_mod
|
|
47
|
-
|
|
47
|
+
define_fetch_fallback(sub) unless pull_mod
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def define_fetch_fallback(sub)
|
|
51
51
|
Logging.warn(
|
|
52
52
|
'PullSubscription mixin unavailable; using shim fetch implementation',
|
|
53
53
|
tag: 'JetstreamBridge::Consumer'
|
|
@@ -78,7 +78,7 @@ module JetstreamBridge
|
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
def
|
|
81
|
+
def attach_js_subscription_metadata(sub)
|
|
82
82
|
js_sub_class = begin
|
|
83
83
|
NATS::JetStream.const_get(:JS).const_get(:Sub)
|
|
84
84
|
rescue NameError
|