jetstream_bridge 5.1.0 → 7.0.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 +37 -0
- data/README.md +6 -3
- data/docs/API.md +395 -0
- data/docs/ARCHITECTURE.md +66 -4
- data/docs/GETTING_STARTED.md +72 -1
- data/docs/PRODUCTION.md +1 -1
- data/docs/RESTRICTED_PERMISSIONS.md +1 -1
- 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.rb +122 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +51 -33
- 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 +44 -106
- data/lib/jetstream_bridge/core/connection.rb +2 -2
- 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 +69 -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 +7 -12
- metadata +5 -2
|
@@ -417,7 +417,7 @@ If you're working on the jetstream_bridge gem itself:
|
|
|
417
417
|
gem build jetstream_bridge.gemspec
|
|
418
418
|
|
|
419
419
|
# Install locally for testing
|
|
420
|
-
gem install ./jetstream_bridge-
|
|
420
|
+
gem install ./jetstream_bridge-7.0.0.gem
|
|
421
421
|
|
|
422
422
|
# Or update in your application's Gemfile.lock
|
|
423
423
|
bundle update jetstream_bridge
|
data/docs/TESTING.md
CHANGED
|
@@ -47,7 +47,7 @@ RSpec.describe MyService do
|
|
|
47
47
|
name: 'test-jetstream-bridge-stream',
|
|
48
48
|
subjects: ['test.>']
|
|
49
49
|
)
|
|
50
|
-
allow(JetstreamBridge::Topology).to receive(:
|
|
50
|
+
allow(JetstreamBridge::Topology).to receive(:provision!)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
after do
|
|
@@ -280,7 +280,7 @@ before do
|
|
|
280
280
|
)
|
|
281
281
|
|
|
282
282
|
# Allow topology check to succeed
|
|
283
|
-
allow(JetstreamBridge::Topology).to receive(:
|
|
283
|
+
allow(JetstreamBridge::Topology).to receive(:provision!)
|
|
284
284
|
|
|
285
285
|
JetstreamBridge.configure do |config|
|
|
286
286
|
config.stream_name = 'jetstream-bridge-stream'
|
|
@@ -391,7 +391,7 @@ storage.reset!
|
|
|
391
391
|
3. **Test both success and failure paths**: Use the mock to simulate errors
|
|
392
392
|
4. **Verify message content**: Check that envelopes are correctly formatted
|
|
393
393
|
5. **Test idempotency**: Verify duplicate detection and redelivery behavior
|
|
394
|
-
6. **Mock topology setup**: Remember to stub `JetstreamBridge::Topology.
|
|
394
|
+
6. **Mock topology setup**: Remember to stub `JetstreamBridge::Topology.provision!`
|
|
395
395
|
|
|
396
396
|
## Examples
|
|
397
397
|
|
data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb
CHANGED
|
@@ -3,22 +3,38 @@
|
|
|
3
3
|
class CreateJetstreamInboxEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
def change
|
|
5
5
|
create_table :jetstream_inbox_events do |t|
|
|
6
|
-
|
|
7
|
-
t.string :
|
|
8
|
-
t.
|
|
9
|
-
t.
|
|
10
|
-
t.string :
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
t.
|
|
14
|
-
t.
|
|
15
|
-
t.
|
|
16
|
-
|
|
6
|
+
# Event identification and deduplication
|
|
7
|
+
t.string :event_id, null: false # unique event identifier for deduplication
|
|
8
|
+
t.string :event_type, null: false # type of event (e.g., 'created', 'updated')
|
|
9
|
+
t.string :resource_type # type of resource (e.g., 'organization', 'user')
|
|
10
|
+
t.string :resource_id # ID of the resource
|
|
11
|
+
|
|
12
|
+
# Event payload and metadata
|
|
13
|
+
t.text :payload, null: false # full event payload as JSON
|
|
14
|
+
t.string :subject # NATS subject (optional)
|
|
15
|
+
t.jsonb :headers, default: {} # NATS message headers (optional)
|
|
16
|
+
|
|
17
|
+
# NATS JetStream metadata (optional - useful for debugging)
|
|
18
|
+
t.string :stream # JetStream stream name
|
|
19
|
+
t.bigint :stream_seq # stream sequence number
|
|
20
|
+
t.integer :deliveries # number of delivery attempts
|
|
21
|
+
|
|
22
|
+
# Processing status and error tracking
|
|
23
|
+
t.string :status, null: false, default: 'received' # received|processing|processed|failed
|
|
24
|
+
t.text :error_message # error message if processing failed
|
|
25
|
+
t.integer :processing_attempts, null: false, default: 0 # number of processing attempts
|
|
26
|
+
|
|
27
|
+
# Timestamps
|
|
28
|
+
t.datetime :received_at # when the event was first received
|
|
29
|
+
t.datetime :processed_at # when the event was successfully processed
|
|
30
|
+
t.datetime :failed_at # when the event failed processing
|
|
17
31
|
t.timestamps
|
|
18
32
|
end
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
add_index :jetstream_inbox_events,
|
|
34
|
+
# Indexes for efficient querying and deduplication
|
|
35
|
+
add_index :jetstream_inbox_events, :event_id, unique: true
|
|
22
36
|
add_index :jetstream_inbox_events, :status
|
|
37
|
+
add_index :jetstream_inbox_events, :created_at
|
|
38
|
+
add_index :jetstream_inbox_events, [:stream, :stream_seq], unique: true, where: 'stream IS NOT NULL AND stream_seq IS NOT NULL'
|
|
23
39
|
end
|
|
24
40
|
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'core/logging'
|
|
4
|
+
require_relative 'core/config'
|
|
5
|
+
|
|
6
|
+
module JetstreamBridge
|
|
7
|
+
# Convenience helpers to keep example configuration lean and consistent.
|
|
8
|
+
module ConfigHelpers
|
|
9
|
+
DEFAULT_STREAM = 'sync-stream'
|
|
10
|
+
DEFAULT_BACKOFF = %w[1s 5s 15s 30s 60s].freeze
|
|
11
|
+
DEFAULT_ACK_WAIT = '30s'
|
|
12
|
+
DEFAULT_MAX_DELIVER = 5
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Configure a bidirectional bridge with sensible defaults.
|
|
17
|
+
#
|
|
18
|
+
# @param app_name [String] Name of the local app (publisher + consumer)
|
|
19
|
+
# @param destination_app [String] Remote app to sync with
|
|
20
|
+
# @param mode [Symbol] :non_restrictive (auto provision) or :restrictive
|
|
21
|
+
# @param stream_name [String] JetStream stream name
|
|
22
|
+
# @param nats_url [String] NATS connection URL(s)
|
|
23
|
+
# @param use_outbox [Boolean] Enable transactional outbox pattern
|
|
24
|
+
# @param use_inbox [Boolean] Enable idempotent inbox pattern
|
|
25
|
+
# @param logger [Logger,nil] Logger to attach to configuration
|
|
26
|
+
# @param overrides [Hash] Additional config overrides applied verbatim
|
|
27
|
+
#
|
|
28
|
+
# @yield [config] Optional block for further customization
|
|
29
|
+
#
|
|
30
|
+
# @return [JetstreamBridge::Config]
|
|
31
|
+
def configure_bidirectional(
|
|
32
|
+
app_name:,
|
|
33
|
+
destination_app:,
|
|
34
|
+
mode: :non_restrictive,
|
|
35
|
+
stream_name: DEFAULT_STREAM,
|
|
36
|
+
nats_url: ENV.fetch('NATS_URL', 'nats://nats:4222'),
|
|
37
|
+
use_outbox: true,
|
|
38
|
+
use_inbox: true,
|
|
39
|
+
logger: nil,
|
|
40
|
+
**overrides
|
|
41
|
+
)
|
|
42
|
+
JetstreamBridge.configure do |config|
|
|
43
|
+
apply_base_settings(config, app_name, destination_app, stream_name, nats_url, use_outbox, use_inbox, mode,
|
|
44
|
+
overrides)
|
|
45
|
+
apply_reliability_defaults(config, overrides)
|
|
46
|
+
config.logger = logger if logger
|
|
47
|
+
apply_overrides(config, overrides)
|
|
48
|
+
yield(config) if block_given?
|
|
49
|
+
|
|
50
|
+
config
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Wire JetstreamBridge lifecycle into Rails boot/shutdown.
|
|
55
|
+
#
|
|
56
|
+
# Safe to call multiple times; startup! is idempotent.
|
|
57
|
+
#
|
|
58
|
+
# @param logger [Logger,nil] Logger to use for lifecycle messages
|
|
59
|
+
# @return [void]
|
|
60
|
+
def setup_rails_lifecycle(logger: nil, rails_app: nil)
|
|
61
|
+
app = rails_app
|
|
62
|
+
app ||= Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
63
|
+
|
|
64
|
+
# Gracefully no-op when Rails isn't available (e.g., non-Rails runtimes or early boot)
|
|
65
|
+
return unless app
|
|
66
|
+
|
|
67
|
+
effective_logger = logger || default_rails_logger(app)
|
|
68
|
+
|
|
69
|
+
app.config.after_initialize do
|
|
70
|
+
JetstreamBridge.startup!
|
|
71
|
+
effective_logger&.info('JetStream Bridge connected successfully')
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
effective_logger&.error("Failed to connect to JetStream: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Kernel.at_exit { JetstreamBridge.shutdown! }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def restrictive?(mode)
|
|
80
|
+
mode.to_sym == :restrictive
|
|
81
|
+
end
|
|
82
|
+
private_class_method :restrictive?
|
|
83
|
+
|
|
84
|
+
def apply_base_settings(config, app_name, destination_app, stream_name, nats_url, use_outbox, use_inbox, mode,
|
|
85
|
+
overrides)
|
|
86
|
+
config.nats_urls = nats_url
|
|
87
|
+
config.app_name = app_name
|
|
88
|
+
config.destination_app = destination_app
|
|
89
|
+
config.stream_name = stream_name
|
|
90
|
+
config.auto_provision = !restrictive?(mode)
|
|
91
|
+
config.use_outbox = use_outbox
|
|
92
|
+
config.use_inbox = use_inbox
|
|
93
|
+
config.consumer_mode = overrides.fetch(:consumer_mode, config.consumer_mode || :pull)
|
|
94
|
+
end
|
|
95
|
+
private_class_method :apply_base_settings
|
|
96
|
+
|
|
97
|
+
def apply_reliability_defaults(config, overrides)
|
|
98
|
+
config.max_deliver = overrides.fetch(:max_deliver, DEFAULT_MAX_DELIVER)
|
|
99
|
+
config.ack_wait = overrides.fetch(:ack_wait, DEFAULT_ACK_WAIT)
|
|
100
|
+
config.backoff = overrides.fetch(:backoff, DEFAULT_BACKOFF)
|
|
101
|
+
end
|
|
102
|
+
private_class_method :apply_reliability_defaults
|
|
103
|
+
|
|
104
|
+
def apply_overrides(config, overrides)
|
|
105
|
+
ignored = [:max_deliver, :ack_wait, :backoff, :consumer_mode]
|
|
106
|
+
overrides.each do |key, value|
|
|
107
|
+
next if ignored.include?(key)
|
|
108
|
+
|
|
109
|
+
setter = "#{key}="
|
|
110
|
+
config.public_send(setter, value) if config.respond_to?(setter)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
private_class_method :apply_overrides
|
|
114
|
+
|
|
115
|
+
def default_rails_logger(app = nil)
|
|
116
|
+
return app.logger if app.respond_to?(:logger)
|
|
117
|
+
|
|
118
|
+
defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
119
|
+
end
|
|
120
|
+
private_class_method :default_rails_logger
|
|
121
|
+
end
|
|
122
|
+
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
|
|
@@ -63,6 +64,8 @@ module JetstreamBridge
|
|
|
63
64
|
attr_reader :batch_size
|
|
64
65
|
# @return [MiddlewareChain] Middleware chain for processing
|
|
65
66
|
attr_reader :middleware_chain
|
|
67
|
+
# Expose grouped state objects for observability/testing
|
|
68
|
+
attr_reader :processing_state, :lifecycle_state, :connection_state
|
|
66
69
|
|
|
67
70
|
# Initialize a new Consumer instance.
|
|
68
71
|
#
|
|
@@ -100,15 +103,9 @@ module JetstreamBridge
|
|
|
100
103
|
|
|
101
104
|
@batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
|
|
102
105
|
@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
|
|
106
|
+
@processing_state = ProcessingState.new(idle_backoff: IDLE_SLEEP_SECS)
|
|
107
|
+
@lifecycle_state = LifecycleState.new
|
|
108
|
+
@connection_state = ConnectionState.new
|
|
112
109
|
# Use existing connection (should already be established)
|
|
113
110
|
@jts = Connection.jetstream
|
|
114
111
|
raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
|
|
@@ -198,24 +195,33 @@ module JetstreamBridge
|
|
|
198
195
|
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{JetstreamBridge.config.destination_subject})…",
|
|
199
196
|
tag: 'JetstreamBridge::Consumer'
|
|
200
197
|
)
|
|
201
|
-
while @running
|
|
198
|
+
while @lifecycle_state.running
|
|
202
199
|
# 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
|
-
|
|
200
|
+
if @lifecycle_state.signal_received && !@lifecycle_state.signal_logged
|
|
201
|
+
Logging.info("Received #{@lifecycle_state.signal_received}, stopping consumer...",
|
|
202
|
+
tag: 'JetstreamBridge::Consumer')
|
|
203
|
+
@lifecycle_state.signal_logged = true
|
|
206
204
|
end
|
|
207
205
|
|
|
206
|
+
Logging.debug(
|
|
207
|
+
"Fetching messages (iteration=#{@processing_state.iterations}, batch_size=#{@batch_size})...",
|
|
208
|
+
tag: 'JetstreamBridge::Consumer'
|
|
209
|
+
)
|
|
208
210
|
processed = process_batch
|
|
211
|
+
Logging.debug(
|
|
212
|
+
"Processed #{processed} messages",
|
|
213
|
+
tag: 'JetstreamBridge::Consumer'
|
|
214
|
+
)
|
|
209
215
|
idle_sleep(processed)
|
|
210
216
|
|
|
211
|
-
@iterations += 1
|
|
217
|
+
@processing_state.iterations += 1
|
|
212
218
|
|
|
213
219
|
# Periodic health checks every 10 minutes (600 seconds)
|
|
214
220
|
perform_health_check_if_due
|
|
215
221
|
end
|
|
216
222
|
|
|
217
223
|
# Drain in-flight messages before exiting
|
|
218
|
-
drain_inflight_messages if @shutdown_requested
|
|
224
|
+
drain_inflight_messages if @lifecycle_state.shutdown_requested
|
|
219
225
|
Logging.info("Consumer #{@durable} stopped gracefully", tag: 'JetstreamBridge::Consumer')
|
|
220
226
|
end
|
|
221
227
|
|
|
@@ -245,8 +251,7 @@ module JetstreamBridge
|
|
|
245
251
|
# consumer.stop! # Stop after 10 seconds
|
|
246
252
|
#
|
|
247
253
|
def stop!
|
|
248
|
-
@
|
|
249
|
-
@running = false
|
|
254
|
+
@lifecycle_state.stop!
|
|
250
255
|
Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
|
|
251
256
|
end
|
|
252
257
|
|
|
@@ -264,16 +269,21 @@ module JetstreamBridge
|
|
|
264
269
|
|
|
265
270
|
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
|
266
271
|
def process_batch
|
|
272
|
+
Logging.debug('Calling fetch_messages...', tag: 'JetstreamBridge::Consumer')
|
|
267
273
|
msgs = fetch_messages
|
|
274
|
+
Logging.debug("Fetched #{msgs&.size || 0} messages", tag: 'JetstreamBridge::Consumer')
|
|
268
275
|
return 0 if msgs.nil? || msgs.empty?
|
|
269
276
|
|
|
270
277
|
msgs.sum { |m| process_one(m) }
|
|
271
|
-
rescue NATS::Timeout, NATS::IO::Timeout
|
|
278
|
+
rescue NATS::Timeout, NATS::IO::Timeout => e
|
|
279
|
+
Logging.debug("Fetch timeout: #{e.class}", tag: 'JetstreamBridge::Consumer')
|
|
272
280
|
0
|
|
273
281
|
rescue NATS::JetStream::Error => e
|
|
282
|
+
Logging.error("JetStream error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
274
283
|
handle_js_error(e)
|
|
275
284
|
rescue StandardError => e
|
|
276
285
|
Logging.error("Unexpected process_batch error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
286
|
+
Logging.error("Backtrace: #{e.backtrace.first(5).join("\n")}", tag: 'JetstreamBridge::Consumer')
|
|
277
287
|
0
|
|
278
288
|
end
|
|
279
289
|
|
|
@@ -288,7 +298,17 @@ module JetstreamBridge
|
|
|
288
298
|
end
|
|
289
299
|
|
|
290
300
|
def fetch_messages_pull
|
|
291
|
-
|
|
301
|
+
Logging.debug(
|
|
302
|
+
"fetch_messages_pull called (@psub=#{@psub.class}, batch=#{@batch_size}, timeout=#{FETCH_TIMEOUT_SECS})",
|
|
303
|
+
tag: 'JetstreamBridge::Consumer'
|
|
304
|
+
)
|
|
305
|
+
result = @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
|
|
306
|
+
Logging.debug("fetch returned #{result&.size || 0} messages", tag: 'JetstreamBridge::Consumer')
|
|
307
|
+
result
|
|
308
|
+
rescue StandardError => e
|
|
309
|
+
Logging.error("fetch_messages_pull error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
310
|
+
Logging.error("Backtrace: #{e.backtrace.first(5).join("\n")}", tag: 'JetstreamBridge::Consumer')
|
|
311
|
+
raise
|
|
292
312
|
end
|
|
293
313
|
|
|
294
314
|
def fetch_messages_push
|
|
@@ -321,11 +341,11 @@ module JetstreamBridge
|
|
|
321
341
|
def handle_js_error(error)
|
|
322
342
|
if recoverable_consumer_error?(error)
|
|
323
343
|
# Increment reconnect attempts and calculate exponential backoff
|
|
324
|
-
@reconnect_attempts += 1
|
|
325
|
-
backoff_secs = calculate_reconnect_backoff(@reconnect_attempts)
|
|
344
|
+
@connection_state.reconnect_attempts += 1
|
|
345
|
+
backoff_secs = calculate_reconnect_backoff(@connection_state.reconnect_attempts)
|
|
326
346
|
|
|
327
347
|
Logging.warn(
|
|
328
|
-
"Recovering subscription after error (attempt #{@reconnect_attempts}): " \
|
|
348
|
+
"Recovering subscription after error (attempt #{@connection_state.reconnect_attempts}): " \
|
|
329
349
|
"#{error.class} #{error.message}, waiting #{backoff_secs}s",
|
|
330
350
|
tag: 'JetstreamBridge::Consumer'
|
|
331
351
|
)
|
|
@@ -334,7 +354,7 @@ module JetstreamBridge
|
|
|
334
354
|
ensure_subscription!
|
|
335
355
|
|
|
336
356
|
# Reset counter on successful reconnection
|
|
337
|
-
@reconnect_attempts = 0
|
|
357
|
+
@connection_state.reconnect_attempts = 0
|
|
338
358
|
else
|
|
339
359
|
Logging.error("Fetch failed (non-recoverable): #{error.class} #{error.message}", tag: 'JetstreamBridge::Consumer')
|
|
340
360
|
end
|
|
@@ -366,10 +386,10 @@ module JetstreamBridge
|
|
|
366
386
|
def idle_sleep(processed)
|
|
367
387
|
if processed.zero?
|
|
368
388
|
# 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))
|
|
389
|
+
@processing_state.idle_backoff = [@processing_state.idle_backoff * 1.5, MAX_IDLE_BACKOFF_SECS].min
|
|
390
|
+
sleep(@processing_state.idle_backoff + (rand * 0.01))
|
|
371
391
|
else
|
|
372
|
-
@idle_backoff = IDLE_SLEEP_SECS
|
|
392
|
+
@processing_state.idle_backoff = IDLE_SLEEP_SECS
|
|
373
393
|
end
|
|
374
394
|
end
|
|
375
395
|
|
|
@@ -378,9 +398,7 @@ module JetstreamBridge
|
|
|
378
398
|
Signal.trap(sig) do
|
|
379
399
|
# CRITICAL: Only set flags in trap context, no I/O or mutex operations
|
|
380
400
|
# Logging and other operations are unsafe from signal handlers
|
|
381
|
-
@
|
|
382
|
-
@running = false
|
|
383
|
-
@shutdown_requested = true
|
|
401
|
+
@lifecycle_state.signal!(sig)
|
|
384
402
|
end
|
|
385
403
|
end
|
|
386
404
|
rescue ArgumentError => e
|
|
@@ -390,16 +408,16 @@ module JetstreamBridge
|
|
|
390
408
|
|
|
391
409
|
def perform_health_check_if_due
|
|
392
410
|
now = Time.now
|
|
393
|
-
time_since_check = now - @last_health_check
|
|
411
|
+
time_since_check = now - @connection_state.last_health_check
|
|
394
412
|
|
|
395
413
|
return unless time_since_check >= 600 # 10 minutes
|
|
396
414
|
|
|
397
|
-
@
|
|
398
|
-
uptime = now
|
|
415
|
+
@connection_state.mark_health_check(now)
|
|
416
|
+
uptime = @lifecycle_state.uptime(now)
|
|
399
417
|
memory_mb = memory_usage_mb
|
|
400
418
|
|
|
401
419
|
Logging.info(
|
|
402
|
-
"Consumer health: iterations=#{@iterations}, " \
|
|
420
|
+
"Consumer health: iterations=#{@processing_state.iterations}, " \
|
|
403
421
|
"memory=#{memory_mb}MB, uptime=#{uptime.round}s",
|
|
404
422
|
tag: 'JetstreamBridge::Consumer'
|
|
405
423
|
)
|
|
@@ -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
|