jetstream_bridge 2.9.0 → 3.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 +164 -0
- data/LICENSE +21 -0
- data/README.md +379 -0
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
- data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +61 -13
- data/lib/generators/jetstream_bridge/install/install_generator.rb +4 -2
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +1 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +50 -9
- data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +8 -2
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -61
- data/lib/jetstream_bridge/consumer/message_processor.rb +105 -33
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +13 -2
- data/lib/jetstream_bridge/core/config.rb +37 -1
- data/lib/jetstream_bridge/core/connection.rb +80 -3
- data/lib/jetstream_bridge/core/connection_factory.rb +102 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +107 -0
- data/lib/jetstream_bridge/core/duration.rb +8 -1
- data/lib/jetstream_bridge/core/logging.rb +20 -7
- data/lib/jetstream_bridge/core/model_utils.rb +4 -3
- data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
- data/lib/jetstream_bridge/errors.rb +39 -0
- data/lib/jetstream_bridge/inbox_event.rb +4 -4
- data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
- data/lib/jetstream_bridge/models/subject.rb +94 -0
- data/lib/jetstream_bridge/outbox_event.rb +3 -1
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
- data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
- data/lib/jetstream_bridge/railtie.rb +35 -1
- data/lib/jetstream_bridge/tasks/install.rake +99 -0
- data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
- data/lib/jetstream_bridge/topology/stream.rb +16 -8
- data/lib/jetstream_bridge/topology/subject_matcher.rb +17 -7
- data/lib/jetstream_bridge/topology/topology.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +63 -6
- metadata +51 -10
- data/lib/jetstream_bridge/consumer/backoff_strategy.rb +0 -24
- data/lib/jetstream_bridge/consumer/consumer_config.rb +0 -26
- data/lib/jetstream_bridge/consumer/message_context.rb +0 -22
|
@@ -1,24 +1,72 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# JetstreamBridge configuration
|
|
4
|
+
# See https://github.com/your-org/jetstream_bridge for full documentation
|
|
5
|
+
#
|
|
6
|
+
# This initializer configures the JetStream Bridge gem for Rails applications.
|
|
7
|
+
# The gem provides reliable, production-ready message passing between services
|
|
8
|
+
# using NATS JetStream with support for outbox/inbox patterns, DLQ, and more.
|
|
4
9
|
JetstreamBridge.configure do |config|
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
config.
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# NATS Connection Settings
|
|
12
|
+
# ============================================================================
|
|
13
|
+
# NATS server URLs (comma-separated for cluster)
|
|
14
|
+
config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
|
|
10
15
|
|
|
11
|
-
#
|
|
16
|
+
# Environment identifier (e.g., 'development', 'production')
|
|
17
|
+
# Used in stream names and subject routing
|
|
18
|
+
config.env = ENV.fetch('NATS_ENV', Rails.env)
|
|
19
|
+
|
|
20
|
+
# Application name (used in subject routing)
|
|
21
|
+
config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
|
|
22
|
+
|
|
23
|
+
# Destination app for cross-app sync (REQUIRED for publishing/consuming)
|
|
24
|
+
config.destination_app = ENV.fetch('DESTINATION_APP', nil)
|
|
25
|
+
|
|
26
|
+
# ============================================================================
|
|
27
|
+
# Consumer Settings
|
|
28
|
+
# ============================================================================
|
|
29
|
+
# Maximum delivery attempts before moving message to DLQ
|
|
12
30
|
config.max_deliver = 5
|
|
13
|
-
config.ack_wait = '30s'
|
|
14
|
-
config.backoff = %w[1s 5s 15s 30s 60s]
|
|
15
31
|
|
|
32
|
+
# How long to wait for message acknowledgment
|
|
33
|
+
config.ack_wait = '30s'
|
|
34
|
+
|
|
35
|
+
# Exponential backoff delays between retry attempts
|
|
36
|
+
config.backoff = %w[1s 5s 15s 30s 60s]
|
|
37
|
+
|
|
38
|
+
# ============================================================================
|
|
16
39
|
# Reliability Features
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# Enable transactional outbox pattern for guaranteed message delivery
|
|
17
42
|
config.use_outbox = false
|
|
18
|
-
config.use_inbox = false
|
|
19
|
-
config.use_dlq = true
|
|
20
43
|
|
|
21
|
-
#
|
|
44
|
+
# Enable inbox pattern for exactly-once processing (idempotency)
|
|
45
|
+
config.use_inbox = false
|
|
46
|
+
|
|
47
|
+
# Enable Dead Letter Queue for failed messages
|
|
48
|
+
config.use_dlq = true
|
|
49
|
+
|
|
50
|
+
# ============================================================================
|
|
51
|
+
# Active Record Models
|
|
52
|
+
# ============================================================================
|
|
53
|
+
# Override these if you've created custom model classes
|
|
22
54
|
config.outbox_model = 'JetstreamBridge::OutboxEvent'
|
|
23
|
-
config.inbox_model
|
|
55
|
+
config.inbox_model = 'JetstreamBridge::InboxEvent'
|
|
56
|
+
|
|
57
|
+
# ============================================================================
|
|
58
|
+
# Logging
|
|
59
|
+
# ============================================================================
|
|
60
|
+
# Custom logger (defaults to Rails.logger if not set)
|
|
61
|
+
# config.logger = Rails.logger
|
|
24
62
|
end
|
|
63
|
+
|
|
64
|
+
# Validate configuration (optional but recommended for development)
|
|
65
|
+
# Uncomment to enable validation on Rails boot
|
|
66
|
+
# begin
|
|
67
|
+
# JetstreamBridge.config.validate!
|
|
68
|
+
# Rails.logger.info "[JetStream Bridge] Configuration validated successfully"
|
|
69
|
+
# rescue JetstreamBridge::ConfigurationError => e
|
|
70
|
+
# Rails.logger.error "[JetStream Bridge] Configuration error: #{e.message}"
|
|
71
|
+
# raise if Rails.env.production?
|
|
72
|
+
# end
|
|
@@ -8,11 +8,13 @@ module JetstreamBridge
|
|
|
8
8
|
class InstallGenerator < Rails::Generators::Base
|
|
9
9
|
desc 'Creates JetstreamBridge initializer and migrations'
|
|
10
10
|
def create_initializer
|
|
11
|
-
Rails::Generators.invoke('jetstream_bridge:initializer', [], behavior: behavior,
|
|
11
|
+
Rails::Generators.invoke('jetstream_bridge:initializer', [], behavior: behavior,
|
|
12
|
+
destination_root: destination_root)
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def create_migrations
|
|
15
|
-
Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior,
|
|
16
|
+
Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior,
|
|
17
|
+
destination_root: destination_root)
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
end
|
|
@@ -7,7 +7,6 @@ require_relative '../core/duration'
|
|
|
7
7
|
require_relative '../core/logging'
|
|
8
8
|
require_relative '../core/config'
|
|
9
9
|
require_relative '../core/model_utils'
|
|
10
|
-
require_relative 'consumer_config'
|
|
11
10
|
require_relative 'message_processor'
|
|
12
11
|
require_relative 'subscription_manager'
|
|
13
12
|
require_relative 'inbox/inbox_processor'
|
|
@@ -20,15 +19,15 @@ module JetstreamBridge
|
|
|
20
19
|
IDLE_SLEEP_SECS = 0.05
|
|
21
20
|
MAX_IDLE_BACKOFF_SECS = 1.0
|
|
22
21
|
|
|
23
|
-
def initialize(durable_name:
|
|
24
|
-
batch_size: DEFAULT_BATCH_SIZE, &block)
|
|
22
|
+
def initialize(durable_name: nil, batch_size: nil, &block)
|
|
25
23
|
raise ArgumentError, 'handler block required' unless block_given?
|
|
26
24
|
|
|
27
25
|
@handler = block
|
|
28
|
-
@batch_size = Integer(batch_size)
|
|
29
|
-
@durable = durable_name
|
|
26
|
+
@batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
|
|
27
|
+
@durable = durable_name || JetstreamBridge.config.durable_name
|
|
30
28
|
@idle_backoff = IDLE_SLEEP_SECS
|
|
31
29
|
@running = true
|
|
30
|
+
@shutdown_requested = false
|
|
32
31
|
@jts = Connection.connect!
|
|
33
32
|
|
|
34
33
|
ensure_destination!
|
|
@@ -38,6 +37,7 @@ module JetstreamBridge
|
|
|
38
37
|
@inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
|
|
39
38
|
|
|
40
39
|
ensure_subscription!
|
|
40
|
+
setup_signal_handlers
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def run!
|
|
@@ -49,11 +49,17 @@ module JetstreamBridge
|
|
|
49
49
|
processed = process_batch
|
|
50
50
|
idle_sleep(processed)
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
# Drain in-flight messages before exiting
|
|
54
|
+
drain_inflight_messages if @shutdown_requested
|
|
55
|
+
Logging.info("Consumer #{@durable} stopped gracefully", tag: 'JetstreamBridge::Consumer')
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
# Allow external callers to stop a long-running loop gracefully.
|
|
55
59
|
def stop!
|
|
60
|
+
@shutdown_requested = true
|
|
56
61
|
@running = false
|
|
62
|
+
Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
private
|
|
@@ -103,15 +109,15 @@ module JetstreamBridge
|
|
|
103
109
|
0
|
|
104
110
|
end
|
|
105
111
|
|
|
106
|
-
def handle_js_error(
|
|
107
|
-
if recoverable_consumer_error?(
|
|
112
|
+
def handle_js_error(error)
|
|
113
|
+
if recoverable_consumer_error?(error)
|
|
108
114
|
Logging.warn(
|
|
109
|
-
"Recovering subscription after error: #{
|
|
115
|
+
"Recovering subscription after error: #{error.class} #{error.message}",
|
|
110
116
|
tag: 'JetstreamBridge::Consumer'
|
|
111
117
|
)
|
|
112
118
|
ensure_subscription!
|
|
113
119
|
else
|
|
114
|
-
Logging.error("Fetch failed (non-recoverable): #{
|
|
120
|
+
Logging.error("Fetch failed (non-recoverable): #{error.class} #{error.message}", tag: 'JetstreamBridge::Consumer')
|
|
115
121
|
end
|
|
116
122
|
0
|
|
117
123
|
end
|
|
@@ -140,5 +146,40 @@ module JetstreamBridge
|
|
|
140
146
|
@idle_backoff = IDLE_SLEEP_SECS
|
|
141
147
|
end
|
|
142
148
|
end
|
|
149
|
+
|
|
150
|
+
def setup_signal_handlers
|
|
151
|
+
%w[INT TERM].each do |sig|
|
|
152
|
+
Signal.trap(sig) do
|
|
153
|
+
Logging.info("Received #{sig}, stopping consumer...", tag: 'JetstreamBridge::Consumer')
|
|
154
|
+
stop!
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
rescue ArgumentError => e
|
|
158
|
+
# Signal handlers may not be available in all environments (e.g., threads)
|
|
159
|
+
Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def drain_inflight_messages
|
|
163
|
+
return unless @psub
|
|
164
|
+
|
|
165
|
+
Logging.info("Draining in-flight messages...", tag: 'JetstreamBridge::Consumer')
|
|
166
|
+
# Process any pending messages with a short timeout
|
|
167
|
+
5.times do
|
|
168
|
+
begin
|
|
169
|
+
msgs = @psub.fetch(@batch_size, timeout: 1)
|
|
170
|
+
break if msgs.nil? || msgs.empty?
|
|
171
|
+
|
|
172
|
+
msgs.each { |m| process_one(m) }
|
|
173
|
+
rescue NATS::Timeout, NATS::IO::Timeout
|
|
174
|
+
break
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
Logging.warn("Error draining messages: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
177
|
+
break
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
Logging.info("Drain complete", tag: 'JetstreamBridge::Consumer')
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
Logging.error("Drain failed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
183
|
+
end
|
|
143
184
|
end
|
|
144
185
|
end
|
|
@@ -11,17 +11,20 @@ module JetstreamBridge
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# Sends original payload to DLQ with explanatory headers/context
|
|
14
|
+
# @return [Boolean] true if published successfully, false otherwise
|
|
14
15
|
def publish(msg, ctx, reason:, error_class:, error_message:)
|
|
15
|
-
return unless JetstreamBridge.config.use_dlq
|
|
16
|
+
return true unless JetstreamBridge.config.use_dlq
|
|
16
17
|
|
|
17
18
|
envelope = build_envelope(ctx, reason, error_class, error_message)
|
|
18
19
|
headers = build_headers(msg.header, reason, ctx.deliveries, envelope)
|
|
19
20
|
@jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: headers)
|
|
21
|
+
true
|
|
20
22
|
rescue StandardError => e
|
|
21
23
|
Logging.error(
|
|
22
24
|
"DLQ publish failed event_id=#{ctx.event_id}: #{e.class} #{e.message}",
|
|
23
25
|
tag: 'JetstreamBridge::Consumer'
|
|
24
26
|
)
|
|
27
|
+
false
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
private
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'oj'
|
|
4
|
+
require 'securerandom'
|
|
4
5
|
|
|
5
6
|
module JetstreamBridge
|
|
6
7
|
# Immutable value object for a single NATS message.
|
|
7
8
|
class InboxMessage
|
|
8
9
|
attr_reader :msg, :seq, :deliveries, :stream, :subject, :headers, :body, :raw, :event_id, :now
|
|
9
10
|
|
|
11
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
10
12
|
def self.from_nats(msg)
|
|
11
13
|
meta = (msg.respond_to?(:metadata) && msg.metadata) || nil
|
|
12
14
|
seq = meta.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
|
|
@@ -26,11 +28,14 @@ module JetstreamBridge
|
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
|
|
29
|
-
id = "seq:#{seq}" if id.empty?
|
|
31
|
+
id = "seq:#{seq}" if id.empty? && seq
|
|
32
|
+
id = SecureRandom.uuid if id.to_s.empty?
|
|
30
33
|
|
|
31
34
|
new(msg, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc, consumer)
|
|
32
35
|
end
|
|
36
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
33
37
|
|
|
38
|
+
# rubocop:disable Metrics/ParameterLists
|
|
34
39
|
def initialize(msg, seq, deliveries, stream, subject, headers, body, raw, event_id, now, consumer = nil)
|
|
35
40
|
@msg = msg
|
|
36
41
|
@seq = seq
|
|
@@ -44,6 +49,7 @@ module JetstreamBridge
|
|
|
44
49
|
@now = now
|
|
45
50
|
@consumer = consumer
|
|
46
51
|
end
|
|
52
|
+
# rubocop:enable Metrics/ParameterLists
|
|
47
53
|
|
|
48
54
|
def body_for_store
|
|
49
55
|
body.empty? ? raw : body
|
|
@@ -59,7 +65,7 @@ module JetstreamBridge
|
|
|
59
65
|
|
|
60
66
|
def metadata
|
|
61
67
|
@metadata ||= Struct.new(:num_delivered, :sequence, :consumer, :stream)
|
|
62
|
-
|
|
68
|
+
.new(deliveries, seq, @consumer, stream)
|
|
63
69
|
end
|
|
64
70
|
|
|
65
71
|
def ack(*args, **kwargs)
|
|
@@ -25,75 +25,51 @@ module JetstreamBridge
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def persist_pre(record, msg)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
:deliveries)
|
|
46
|
-
msg.deliveries
|
|
47
|
-
end),
|
|
48
|
-
status: 'processing',
|
|
49
|
-
last_error: nil,
|
|
50
|
-
received_at: (if ModelUtils.has_columns?(@klass,
|
|
51
|
-
:received_at)
|
|
52
|
-
record.received_at || msg.now
|
|
53
|
-
end),
|
|
54
|
-
updated_at: (if ModelUtils.has_columns?(@klass,
|
|
55
|
-
:updated_at)
|
|
56
|
-
msg.now
|
|
57
|
-
end)
|
|
58
|
-
})
|
|
59
|
-
record.save!
|
|
28
|
+
ActiveRecord::Base.transaction do
|
|
29
|
+
attrs = {
|
|
30
|
+
event_id: msg.event_id,
|
|
31
|
+
subject: msg.subject,
|
|
32
|
+
payload: ModelUtils.json_dump(msg.body_for_store),
|
|
33
|
+
headers: ModelUtils.json_dump(msg.headers),
|
|
34
|
+
stream: msg.stream,
|
|
35
|
+
stream_seq: msg.seq,
|
|
36
|
+
deliveries: msg.deliveries,
|
|
37
|
+
status: 'processing',
|
|
38
|
+
last_error: nil,
|
|
39
|
+
received_at: record.respond_to?(:received_at) ? (record.received_at || msg.now) : nil,
|
|
40
|
+
updated_at: record.respond_to?(:updated_at) ? msg.now : nil
|
|
41
|
+
}
|
|
42
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
|
43
|
+
record.save!
|
|
44
|
+
end
|
|
60
45
|
end
|
|
61
46
|
|
|
62
47
|
def persist_post(record)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
end)
|
|
74
|
-
})
|
|
75
|
-
record.save!
|
|
48
|
+
ActiveRecord::Base.transaction do
|
|
49
|
+
now = Time.now.utc
|
|
50
|
+
attrs = {
|
|
51
|
+
status: 'processed',
|
|
52
|
+
processed_at: record.respond_to?(:processed_at) ? now : nil,
|
|
53
|
+
updated_at: record.respond_to?(:updated_at) ? now : nil
|
|
54
|
+
}
|
|
55
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
|
56
|
+
record.save!
|
|
57
|
+
end
|
|
76
58
|
end
|
|
77
59
|
|
|
78
60
|
def persist_failure(record, error)
|
|
79
61
|
return unless record
|
|
80
62
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
updated_at: (if ModelUtils.has_columns?(@klass,
|
|
92
|
-
:updated_at)
|
|
93
|
-
now
|
|
94
|
-
end)
|
|
95
|
-
})
|
|
96
|
-
record.save!
|
|
63
|
+
ActiveRecord::Base.transaction do
|
|
64
|
+
now = Time.now.utc
|
|
65
|
+
attrs = {
|
|
66
|
+
status: 'failed',
|
|
67
|
+
last_error: "#{error.class}: #{error.message}",
|
|
68
|
+
updated_at: record.respond_to?(:updated_at) ? now : nil
|
|
69
|
+
}
|
|
70
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
|
71
|
+
record.save!
|
|
72
|
+
end
|
|
97
73
|
rescue StandardError => e
|
|
98
74
|
Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
|
|
99
75
|
tag: 'JetstreamBridge::Consumer')
|
|
@@ -1,12 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'oj'
|
|
4
|
+
require 'securerandom'
|
|
4
5
|
require_relative '../core/logging'
|
|
5
|
-
require_relative 'message_context'
|
|
6
6
|
require_relative 'dlq_publisher'
|
|
7
|
-
require_relative 'backoff_strategy'
|
|
8
7
|
|
|
9
8
|
module JetstreamBridge
|
|
9
|
+
# Immutable per-message metadata.
|
|
10
|
+
MessageContext = Struct.new(
|
|
11
|
+
:event_id, :deliveries, :subject, :seq, :consumer, :stream,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
) do
|
|
14
|
+
def self.build(msg)
|
|
15
|
+
new(
|
|
16
|
+
event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
|
|
17
|
+
deliveries: msg.metadata&.num_delivered.to_i,
|
|
18
|
+
subject: msg.subject,
|
|
19
|
+
seq: msg.metadata&.sequence,
|
|
20
|
+
consumer: msg.metadata&.consumer,
|
|
21
|
+
stream: msg.metadata&.stream
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Simple exponential backoff strategy for transient failures.
|
|
27
|
+
class BackoffStrategy
|
|
28
|
+
TRANSIENT_ERRORS = [Timeout::Error, IOError].freeze
|
|
29
|
+
MAX_EXPONENT = 6
|
|
30
|
+
MAX_DELAY = 60
|
|
31
|
+
MIN_DELAY = 1
|
|
32
|
+
|
|
33
|
+
# Returns a bounded delay in seconds
|
|
34
|
+
def delay(deliveries, error)
|
|
35
|
+
base = transient?(error) ? 0.5 : 2.0
|
|
36
|
+
power = [deliveries - 1, MAX_EXPONENT].min
|
|
37
|
+
raw = (base * (2**power)).to_i
|
|
38
|
+
raw.clamp(MIN_DELAY, MAX_DELAY)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def transient?(error)
|
|
44
|
+
TRANSIENT_ERRORS.any? { |k| error.is_a?(k) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
10
48
|
# Orchestrates parse → handler → ack/nak → DLQ
|
|
11
49
|
class MessageProcessor
|
|
12
50
|
UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
|
|
@@ -25,12 +63,13 @@ module JetstreamBridge
|
|
|
25
63
|
|
|
26
64
|
process_event(msg, event, ctx)
|
|
27
65
|
rescue StandardError => e
|
|
66
|
+
backtrace = e.backtrace&.first(5)&.join("\n ")
|
|
28
67
|
Logging.error(
|
|
29
68
|
"Processor crashed event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} " \
|
|
30
|
-
"deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}",
|
|
69
|
+
"deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}\n #{backtrace}",
|
|
31
70
|
tag: 'JetstreamBridge::Consumer'
|
|
32
71
|
)
|
|
33
|
-
safe_nak(msg)
|
|
72
|
+
safe_nak(msg, ctx, e)
|
|
34
73
|
end
|
|
35
74
|
|
|
36
75
|
private
|
|
@@ -39,14 +78,22 @@ module JetstreamBridge
|
|
|
39
78
|
data = msg.data
|
|
40
79
|
Oj.load(data, mode: :strict)
|
|
41
80
|
rescue Oj::ParseError => e
|
|
42
|
-
@dlq.publish(msg, ctx,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
dlq_success = @dlq.publish(msg, ctx,
|
|
82
|
+
reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
|
|
83
|
+
if dlq_success
|
|
84
|
+
msg.ack
|
|
85
|
+
Logging.warn(
|
|
86
|
+
"Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
|
87
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
|
|
88
|
+
tag: 'JetstreamBridge::Consumer'
|
|
89
|
+
)
|
|
90
|
+
else
|
|
91
|
+
safe_nak(msg, ctx, e)
|
|
92
|
+
Logging.error(
|
|
93
|
+
"Malformed JSON, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
|
|
94
|
+
tag: 'JetstreamBridge::Consumer'
|
|
95
|
+
)
|
|
96
|
+
end
|
|
50
97
|
nil
|
|
51
98
|
end
|
|
52
99
|
|
|
@@ -58,14 +105,22 @@ module JetstreamBridge
|
|
|
58
105
|
tag: 'JetstreamBridge::Consumer'
|
|
59
106
|
)
|
|
60
107
|
rescue *UNRECOVERABLE_ERRORS => e
|
|
61
|
-
@dlq.publish(msg, ctx,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
108
|
+
dlq_success = @dlq.publish(msg, ctx,
|
|
109
|
+
reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
|
|
110
|
+
if dlq_success
|
|
111
|
+
msg.ack
|
|
112
|
+
Logging.warn(
|
|
113
|
+
"DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
|
114
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
|
|
115
|
+
tag: 'JetstreamBridge::Consumer'
|
|
116
|
+
)
|
|
117
|
+
else
|
|
118
|
+
safe_nak(msg, ctx, e)
|
|
119
|
+
Logging.error(
|
|
120
|
+
"Unrecoverable error, DLQ publish failed, NAKing event_id=#{ctx.event_id}",
|
|
121
|
+
tag: 'JetstreamBridge::Consumer'
|
|
122
|
+
)
|
|
123
|
+
end
|
|
69
124
|
rescue StandardError => e
|
|
70
125
|
ack_or_nak(msg, ctx, e)
|
|
71
126
|
end
|
|
@@ -73,14 +128,28 @@ module JetstreamBridge
|
|
|
73
128
|
def ack_or_nak(msg, ctx, error)
|
|
74
129
|
max_deliver = JetstreamBridge.config.max_deliver.to_i
|
|
75
130
|
if ctx.deliveries >= max_deliver
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
# Only ACK if DLQ publish succeeds
|
|
132
|
+
dlq_success = @dlq.publish(msg, ctx,
|
|
133
|
+
reason: 'max_deliver_exceeded',
|
|
134
|
+
error_class: error.class.name,
|
|
135
|
+
error_message: error.message)
|
|
136
|
+
|
|
137
|
+
if dlq_success
|
|
138
|
+
msg.ack
|
|
139
|
+
Logging.warn(
|
|
140
|
+
"DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
|
141
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
|
|
142
|
+
tag: 'JetstreamBridge::Consumer'
|
|
143
|
+
)
|
|
144
|
+
else
|
|
145
|
+
# NAK to retry DLQ publish
|
|
146
|
+
safe_nak(msg, ctx, error)
|
|
147
|
+
Logging.error(
|
|
148
|
+
"DLQ publish failed at max_deliver, NAKing event_id=#{ctx.event_id} " \
|
|
149
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
|
|
150
|
+
tag: 'JetstreamBridge::Consumer'
|
|
151
|
+
)
|
|
152
|
+
end
|
|
84
153
|
else
|
|
85
154
|
safe_nak(msg, ctx, error)
|
|
86
155
|
Logging.warn(
|
|
@@ -91,11 +160,14 @@ module JetstreamBridge
|
|
|
91
160
|
end
|
|
92
161
|
end
|
|
93
162
|
|
|
94
|
-
def safe_nak(msg, ctx = nil,
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
163
|
+
def safe_nak(msg, ctx = nil, error = nil)
|
|
164
|
+
# Use backoff strategy with error context if available
|
|
165
|
+
if ctx && error && msg.respond_to?(:nak_with_delay)
|
|
166
|
+
delay = @backoff.delay(ctx.deliveries.to_i, error)
|
|
167
|
+
msg.nak_with_delay(delay)
|
|
168
|
+
else
|
|
169
|
+
msg.nak
|
|
170
|
+
end
|
|
99
171
|
rescue StandardError => e
|
|
100
172
|
Logging.error(
|
|
101
173
|
"Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../core/logging'
|
|
4
4
|
require_relative '../core/duration'
|
|
5
|
-
require_relative '../consumer/consumer_config'
|
|
6
5
|
|
|
7
6
|
module JetstreamBridge
|
|
8
7
|
# Encapsulates durable ensure + subscribe for a pull consumer.
|
|
@@ -11,7 +10,7 @@ module JetstreamBridge
|
|
|
11
10
|
@jts = jts
|
|
12
11
|
@durable = durable
|
|
13
12
|
@cfg = cfg
|
|
14
|
-
@desired_cfg =
|
|
13
|
+
@desired_cfg = build_consumer_config(@durable, filter_subject)
|
|
15
14
|
@desired_cfg_norm = normalize_consumer_config(@desired_cfg)
|
|
16
15
|
end
|
|
17
16
|
|
|
@@ -74,6 +73,18 @@ module JetstreamBridge
|
|
|
74
73
|
)
|
|
75
74
|
end
|
|
76
75
|
|
|
76
|
+
def build_consumer_config(durable, filter_subject)
|
|
77
|
+
{
|
|
78
|
+
durable_name: durable,
|
|
79
|
+
filter_subject: filter_subject,
|
|
80
|
+
ack_policy: 'explicit',
|
|
81
|
+
deliver_policy: 'all',
|
|
82
|
+
max_deliver: JetstreamBridge.config.max_deliver,
|
|
83
|
+
ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
|
|
84
|
+
backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
77
88
|
# Normalize both server-returned config objects and our desired hash
|
|
78
89
|
# into a common hash with consistent units/types for accurate comparison.
|
|
79
90
|
def normalize_consumer_config(cfg)
|