jetstream_bridge 5.0.2 → 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 +56 -27
- 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 +56 -10
- 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,13 +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
|
-
@start_time = Time.now
|
|
108
|
-
@iterations = 0
|
|
109
|
-
@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
|
|
110
109
|
# Use existing connection (should already be established)
|
|
111
110
|
@jts = Connection.jetstream
|
|
112
111
|
raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
|
|
@@ -196,18 +195,33 @@ module JetstreamBridge
|
|
|
196
195
|
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{JetstreamBridge.config.destination_subject})…",
|
|
197
196
|
tag: 'JetstreamBridge::Consumer'
|
|
198
197
|
)
|
|
199
|
-
while @running
|
|
198
|
+
while @lifecycle_state.running
|
|
199
|
+
# Check if signal was received and log it (safe from main loop)
|
|
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
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
Logging.debug(
|
|
207
|
+
"Fetching messages (iteration=#{@processing_state.iterations}, batch_size=#{@batch_size})...",
|
|
208
|
+
tag: 'JetstreamBridge::Consumer'
|
|
209
|
+
)
|
|
200
210
|
processed = process_batch
|
|
211
|
+
Logging.debug(
|
|
212
|
+
"Processed #{processed} messages",
|
|
213
|
+
tag: 'JetstreamBridge::Consumer'
|
|
214
|
+
)
|
|
201
215
|
idle_sleep(processed)
|
|
202
216
|
|
|
203
|
-
@iterations += 1
|
|
217
|
+
@processing_state.iterations += 1
|
|
204
218
|
|
|
205
219
|
# Periodic health checks every 10 minutes (600 seconds)
|
|
206
220
|
perform_health_check_if_due
|
|
207
221
|
end
|
|
208
222
|
|
|
209
223
|
# Drain in-flight messages before exiting
|
|
210
|
-
drain_inflight_messages if @shutdown_requested
|
|
224
|
+
drain_inflight_messages if @lifecycle_state.shutdown_requested
|
|
211
225
|
Logging.info("Consumer #{@durable} stopped gracefully", tag: 'JetstreamBridge::Consumer')
|
|
212
226
|
end
|
|
213
227
|
|
|
@@ -237,8 +251,7 @@ module JetstreamBridge
|
|
|
237
251
|
# consumer.stop! # Stop after 10 seconds
|
|
238
252
|
#
|
|
239
253
|
def stop!
|
|
240
|
-
@
|
|
241
|
-
@running = false
|
|
254
|
+
@lifecycle_state.stop!
|
|
242
255
|
Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
|
|
243
256
|
end
|
|
244
257
|
|
|
@@ -256,16 +269,21 @@ module JetstreamBridge
|
|
|
256
269
|
|
|
257
270
|
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
|
258
271
|
def process_batch
|
|
272
|
+
Logging.debug('Calling fetch_messages...', tag: 'JetstreamBridge::Consumer')
|
|
259
273
|
msgs = fetch_messages
|
|
274
|
+
Logging.debug("Fetched #{msgs&.size || 0} messages", tag: 'JetstreamBridge::Consumer')
|
|
260
275
|
return 0 if msgs.nil? || msgs.empty?
|
|
261
276
|
|
|
262
277
|
msgs.sum { |m| process_one(m) }
|
|
263
|
-
rescue NATS::Timeout, NATS::IO::Timeout
|
|
278
|
+
rescue NATS::Timeout, NATS::IO::Timeout => e
|
|
279
|
+
Logging.debug("Fetch timeout: #{e.class}", tag: 'JetstreamBridge::Consumer')
|
|
264
280
|
0
|
|
265
281
|
rescue NATS::JetStream::Error => e
|
|
282
|
+
Logging.error("JetStream error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
266
283
|
handle_js_error(e)
|
|
267
284
|
rescue StandardError => e
|
|
268
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')
|
|
269
287
|
0
|
|
270
288
|
end
|
|
271
289
|
|
|
@@ -280,7 +298,17 @@ module JetstreamBridge
|
|
|
280
298
|
end
|
|
281
299
|
|
|
282
300
|
def fetch_messages_pull
|
|
283
|
-
|
|
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
|
|
284
312
|
end
|
|
285
313
|
|
|
286
314
|
def fetch_messages_push
|
|
@@ -313,11 +341,11 @@ module JetstreamBridge
|
|
|
313
341
|
def handle_js_error(error)
|
|
314
342
|
if recoverable_consumer_error?(error)
|
|
315
343
|
# Increment reconnect attempts and calculate exponential backoff
|
|
316
|
-
@reconnect_attempts += 1
|
|
317
|
-
backoff_secs = calculate_reconnect_backoff(@reconnect_attempts)
|
|
344
|
+
@connection_state.reconnect_attempts += 1
|
|
345
|
+
backoff_secs = calculate_reconnect_backoff(@connection_state.reconnect_attempts)
|
|
318
346
|
|
|
319
347
|
Logging.warn(
|
|
320
|
-
"Recovering subscription after error (attempt #{@reconnect_attempts}): " \
|
|
348
|
+
"Recovering subscription after error (attempt #{@connection_state.reconnect_attempts}): " \
|
|
321
349
|
"#{error.class} #{error.message}, waiting #{backoff_secs}s",
|
|
322
350
|
tag: 'JetstreamBridge::Consumer'
|
|
323
351
|
)
|
|
@@ -326,7 +354,7 @@ module JetstreamBridge
|
|
|
326
354
|
ensure_subscription!
|
|
327
355
|
|
|
328
356
|
# Reset counter on successful reconnection
|
|
329
|
-
@reconnect_attempts = 0
|
|
357
|
+
@connection_state.reconnect_attempts = 0
|
|
330
358
|
else
|
|
331
359
|
Logging.error("Fetch failed (non-recoverable): #{error.class} #{error.message}", tag: 'JetstreamBridge::Consumer')
|
|
332
360
|
end
|
|
@@ -358,18 +386,19 @@ module JetstreamBridge
|
|
|
358
386
|
def idle_sleep(processed)
|
|
359
387
|
if processed.zero?
|
|
360
388
|
# exponential-ish backoff with a tiny jitter to avoid sync across workers
|
|
361
|
-
@idle_backoff = [@idle_backoff * 1.5, MAX_IDLE_BACKOFF_SECS].min
|
|
362
|
-
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))
|
|
363
391
|
else
|
|
364
|
-
@idle_backoff = IDLE_SLEEP_SECS
|
|
392
|
+
@processing_state.idle_backoff = IDLE_SLEEP_SECS
|
|
365
393
|
end
|
|
366
394
|
end
|
|
367
395
|
|
|
368
396
|
def setup_signal_handlers
|
|
369
397
|
%w[INT TERM].each do |sig|
|
|
370
398
|
Signal.trap(sig) do
|
|
371
|
-
|
|
372
|
-
|
|
399
|
+
# CRITICAL: Only set flags in trap context, no I/O or mutex operations
|
|
400
|
+
# Logging and other operations are unsafe from signal handlers
|
|
401
|
+
@lifecycle_state.signal!(sig)
|
|
373
402
|
end
|
|
374
403
|
end
|
|
375
404
|
rescue ArgumentError => e
|
|
@@ -379,16 +408,16 @@ module JetstreamBridge
|
|
|
379
408
|
|
|
380
409
|
def perform_health_check_if_due
|
|
381
410
|
now = Time.now
|
|
382
|
-
time_since_check = now - @last_health_check
|
|
411
|
+
time_since_check = now - @connection_state.last_health_check
|
|
383
412
|
|
|
384
413
|
return unless time_since_check >= 600 # 10 minutes
|
|
385
414
|
|
|
386
|
-
@
|
|
387
|
-
uptime = now
|
|
415
|
+
@connection_state.mark_health_check(now)
|
|
416
|
+
uptime = @lifecycle_state.uptime(now)
|
|
388
417
|
memory_mb = memory_usage_mb
|
|
389
418
|
|
|
390
419
|
Logging.info(
|
|
391
|
-
"Consumer health: iterations=#{@iterations}, " \
|
|
420
|
+
"Consumer health: iterations=#{@processing_state.iterations}, " \
|
|
392
421
|
"memory=#{memory_mb}MB, uptime=#{uptime.round}s",
|
|
393
422
|
tag: 'JetstreamBridge::Consumer'
|
|
394
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
|