jetstream_bridge 2.10.0 → 3.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 +164 -0
- data/LICENSE +21 -0
- data/README.md +397 -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 +58 -13
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +1 -1
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +1 -1
- data/lib/jetstream_bridge/consumer/consumer.rb +42 -1
- data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +3 -1
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -31
- data/lib/jetstream_bridge/consumer/message_processor.rb +65 -31
- data/lib/jetstream_bridge/core/config.rb +35 -0
- data/lib/jetstream_bridge/core/connection.rb +78 -3
- data/lib/jetstream_bridge/core/connection_factory.rb +100 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +119 -0
- data/lib/jetstream_bridge/core/duration.rb +8 -1
- data/lib/jetstream_bridge/core/retry_strategy.rb +136 -0
- data/lib/jetstream_bridge/errors.rb +39 -0
- data/lib/jetstream_bridge/models/event_envelope.rb +136 -0
- data/lib/jetstream_bridge/models/subject.rb +94 -0
- 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 +33 -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 +15 -5
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +65 -0
- metadata +55 -11
|
@@ -1,27 +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'
|
|
24
56
|
|
|
57
|
+
# ============================================================================
|
|
25
58
|
# Logging
|
|
59
|
+
# ============================================================================
|
|
60
|
+
# Custom logger (defaults to Rails.logger if not set)
|
|
26
61
|
# config.logger = Rails.logger
|
|
27
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
|
data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateJetstreamInboxEvents < ActiveRecord::Migration[
|
|
3
|
+
class CreateJetstreamInboxEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
def change
|
|
5
5
|
create_table :jetstream_inbox_events do |t|
|
|
6
6
|
t.string :event_id # preferred dedupe key
|
data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateJetstreamOutboxEvents < ActiveRecord::Migration[
|
|
3
|
+
class CreateJetstreamOutboxEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
def change
|
|
5
5
|
create_table :jetstream_outbox_events do |t|
|
|
6
6
|
t.string :event_id, null: false
|
|
@@ -27,7 +27,8 @@ module JetstreamBridge
|
|
|
27
27
|
@durable = durable_name || JetstreamBridge.config.durable_name
|
|
28
28
|
@idle_backoff = IDLE_SLEEP_SECS
|
|
29
29
|
@running = true
|
|
30
|
-
@
|
|
30
|
+
@shutdown_requested = false
|
|
31
|
+
@jts = Connection.connect!
|
|
31
32
|
|
|
32
33
|
ensure_destination!
|
|
33
34
|
|
|
@@ -36,6 +37,7 @@ module JetstreamBridge
|
|
|
36
37
|
@inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
|
|
37
38
|
|
|
38
39
|
ensure_subscription!
|
|
40
|
+
setup_signal_handlers
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def run!
|
|
@@ -47,11 +49,17 @@ module JetstreamBridge
|
|
|
47
49
|
processed = process_batch
|
|
48
50
|
idle_sleep(processed)
|
|
49
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')
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
# Allow external callers to stop a long-running loop gracefully.
|
|
53
59
|
def stop!
|
|
60
|
+
@shutdown_requested = true
|
|
54
61
|
@running = false
|
|
62
|
+
Logging.info("Consumer #{@durable} shutdown requested", tag: 'JetstreamBridge::Consumer')
|
|
55
63
|
end
|
|
56
64
|
|
|
57
65
|
private
|
|
@@ -138,5 +146,38 @@ module JetstreamBridge
|
|
|
138
146
|
@idle_backoff = IDLE_SLEEP_SECS
|
|
139
147
|
end
|
|
140
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
|
+
msgs = @psub.fetch(@batch_size, timeout: 1)
|
|
169
|
+
break if msgs.nil? || msgs.empty?
|
|
170
|
+
|
|
171
|
+
msgs.each { |m| process_one(m) }
|
|
172
|
+
rescue NATS::Timeout, NATS::IO::Timeout
|
|
173
|
+
break
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
Logging.warn("Error draining messages: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
176
|
+
break
|
|
177
|
+
end
|
|
178
|
+
Logging.info('Drain complete', tag: 'JetstreamBridge::Consumer')
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
Logging.error("Drain failed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
181
|
+
end
|
|
141
182
|
end
|
|
142
183
|
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,6 +1,7 @@
|
|
|
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.
|
|
@@ -27,7 +28,8 @@ module JetstreamBridge
|
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
|
|
30
|
-
id = "seq:#{seq}" if id.empty?
|
|
31
|
+
id = "seq:#{seq}" if id.empty? && seq
|
|
32
|
+
id = SecureRandom.uuid if id.to_s.empty?
|
|
31
33
|
|
|
32
34
|
new(msg, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc, consumer)
|
|
33
35
|
end
|
|
@@ -25,45 +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
|
-
|
|
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
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def persist_post(record)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def persist_failure(record, error)
|
|
57
61
|
return unless record
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
73
|
rescue StandardError => e
|
|
68
74
|
Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
|
|
69
75
|
tag: 'JetstreamBridge::Consumer')
|
|
@@ -63,12 +63,13 @@ module JetstreamBridge
|
|
|
63
63
|
|
|
64
64
|
process_event(msg, event, ctx)
|
|
65
65
|
rescue StandardError => e
|
|
66
|
+
backtrace = e.backtrace&.first(5)&.join("\n ")
|
|
66
67
|
Logging.error(
|
|
67
68
|
"Processor crashed event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} " \
|
|
68
|
-
"deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}",
|
|
69
|
+
"deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}\n #{backtrace}",
|
|
69
70
|
tag: 'JetstreamBridge::Consumer'
|
|
70
71
|
)
|
|
71
|
-
safe_nak(msg)
|
|
72
|
+
safe_nak(msg, ctx, e)
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
private
|
|
@@ -77,14 +78,22 @@ module JetstreamBridge
|
|
|
77
78
|
data = msg.data
|
|
78
79
|
Oj.load(data, mode: :strict)
|
|
79
80
|
rescue Oj::ParseError => e
|
|
80
|
-
@dlq.publish(msg, ctx,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
88
97
|
nil
|
|
89
98
|
end
|
|
90
99
|
|
|
@@ -96,14 +105,22 @@ module JetstreamBridge
|
|
|
96
105
|
tag: 'JetstreamBridge::Consumer'
|
|
97
106
|
)
|
|
98
107
|
rescue *UNRECOVERABLE_ERRORS => e
|
|
99
|
-
@dlq.publish(msg, ctx,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
124
|
rescue StandardError => e
|
|
108
125
|
ack_or_nak(msg, ctx, e)
|
|
109
126
|
end
|
|
@@ -111,14 +128,28 @@ module JetstreamBridge
|
|
|
111
128
|
def ack_or_nak(msg, ctx, error)
|
|
112
129
|
max_deliver = JetstreamBridge.config.max_deliver.to_i
|
|
113
130
|
if ctx.deliveries >= max_deliver
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
122
153
|
else
|
|
123
154
|
safe_nak(msg, ctx, error)
|
|
124
155
|
Logging.warn(
|
|
@@ -129,11 +160,14 @@ module JetstreamBridge
|
|
|
129
160
|
end
|
|
130
161
|
end
|
|
131
162
|
|
|
132
|
-
def safe_nak(msg, ctx = nil,
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
137
171
|
rescue StandardError => e
|
|
138
172
|
Logging.error(
|
|
139
173
|
"Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
|
|
3
5
|
module JetstreamBridge
|
|
4
6
|
class Config
|
|
5
7
|
attr_accessor :destination_app, :nats_urls, :env, :app_name,
|
|
@@ -34,20 +36,53 @@ module JetstreamBridge
|
|
|
34
36
|
# Producer publishes to: {env}.{app}.sync.{dest}
|
|
35
37
|
# Consumer subscribes to: {env}.{dest}.sync.{app}
|
|
36
38
|
def source_subject
|
|
39
|
+
validate_subject_component!(env, 'env')
|
|
40
|
+
validate_subject_component!(app_name, 'app_name')
|
|
41
|
+
validate_subject_component!(destination_app, 'destination_app')
|
|
37
42
|
"#{env}.#{app_name}.sync.#{destination_app}"
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def destination_subject
|
|
46
|
+
validate_subject_component!(env, 'env')
|
|
47
|
+
validate_subject_component!(app_name, 'app_name')
|
|
48
|
+
validate_subject_component!(destination_app, 'destination_app')
|
|
41
49
|
"#{env}.#{destination_app}.sync.#{app_name}"
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
# DLQ
|
|
45
53
|
def dlq_subject
|
|
54
|
+
validate_subject_component!(env, 'env')
|
|
46
55
|
"#{env}.sync.dlq"
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
def durable_name
|
|
50
59
|
"#{env}-#{app_name}-workers"
|
|
51
60
|
end
|
|
61
|
+
|
|
62
|
+
# Validate configuration settings
|
|
63
|
+
def validate!
|
|
64
|
+
errors = []
|
|
65
|
+
errors << 'destination_app is required' if destination_app.to_s.strip.empty?
|
|
66
|
+
errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
|
|
67
|
+
errors << 'env is required' if env.to_s.strip.empty?
|
|
68
|
+
errors << 'app_name is required' if app_name.to_s.strip.empty?
|
|
69
|
+
errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
|
|
70
|
+
errors << 'backoff must be an array' unless backoff.is_a?(Array)
|
|
71
|
+
errors << 'backoff must not be empty' if backoff.is_a?(Array) && backoff.empty?
|
|
72
|
+
|
|
73
|
+
raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def validate_subject_component!(value, name)
|
|
81
|
+
str = value.to_s
|
|
82
|
+
if str.match?(/[.*>]/)
|
|
83
|
+
raise InvalidSubjectError, "#{name} cannot contain NATS wildcards (., *, >): #{value.inspect}"
|
|
84
|
+
end
|
|
85
|
+
raise MissingConfigurationError, "#{name} cannot be empty" if str.strip.empty?
|
|
86
|
+
end
|
|
52
87
|
end
|
|
53
88
|
end
|
|
@@ -9,7 +9,21 @@ require_relative 'config'
|
|
|
9
9
|
require_relative '../topology/topology'
|
|
10
10
|
|
|
11
11
|
module JetstreamBridge
|
|
12
|
-
# Singleton connection to NATS.
|
|
12
|
+
# Singleton connection to NATS with thread-safe initialization.
|
|
13
|
+
#
|
|
14
|
+
# This class manages a single NATS connection for the entire application,
|
|
15
|
+
# ensuring thread-safe access in multi-threaded environments like Rails
|
|
16
|
+
# with Puma or Sidekiq.
|
|
17
|
+
#
|
|
18
|
+
# Thread Safety:
|
|
19
|
+
# - Connection initialization is synchronized with a mutex
|
|
20
|
+
# - The singleton pattern ensures only one connection instance exists
|
|
21
|
+
# - Safe to call from multiple threads/workers simultaneously
|
|
22
|
+
#
|
|
23
|
+
# Example:
|
|
24
|
+
# # Safe from any thread
|
|
25
|
+
# jts = JetstreamBridge::Connection.connect!
|
|
26
|
+
# jts.publish(...)
|
|
13
27
|
class Connection
|
|
14
28
|
include Singleton
|
|
15
29
|
|
|
@@ -23,6 +37,10 @@ module JetstreamBridge
|
|
|
23
37
|
class << self
|
|
24
38
|
# Thread-safe delegator to the singleton instance.
|
|
25
39
|
# Returns a live JetStream context.
|
|
40
|
+
#
|
|
41
|
+
# Safe to call from multiple threads - uses mutex for synchronization.
|
|
42
|
+
#
|
|
43
|
+
# @return [NATS::JetStream::JS] JetStream context
|
|
26
44
|
def connect!
|
|
27
45
|
@__mutex ||= Mutex.new
|
|
28
46
|
@__mutex.synchronize { instance.connect! }
|
|
@@ -56,13 +74,32 @@ module JetstreamBridge
|
|
|
56
74
|
# Ensure topology (streams, subjects, overlap guard, etc.)
|
|
57
75
|
Topology.ensure!(@jts)
|
|
58
76
|
|
|
77
|
+
@connected_at = Time.now.utc
|
|
59
78
|
@jts
|
|
60
79
|
end
|
|
61
80
|
|
|
81
|
+
# Public API for checking connection status
|
|
82
|
+
# @return [Boolean] true if NATS client is connected and JetStream is healthy
|
|
83
|
+
def connected?
|
|
84
|
+
@nc&.connected? && @jts && jetstream_healthy?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Public API for getting connection timestamp
|
|
88
|
+
# @return [Time, nil] timestamp when connection was established
|
|
89
|
+
attr_reader :connected_at
|
|
90
|
+
|
|
62
91
|
private
|
|
63
92
|
|
|
64
|
-
def
|
|
65
|
-
|
|
93
|
+
def jetstream_healthy?
|
|
94
|
+
# Verify JetStream responds to simple API call
|
|
95
|
+
@jts.account_info
|
|
96
|
+
true
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Logging.warn(
|
|
99
|
+
"JetStream health check failed: #{e.class} #{e.message}",
|
|
100
|
+
tag: 'JetstreamBridge::Connection'
|
|
101
|
+
)
|
|
102
|
+
false
|
|
66
103
|
end
|
|
67
104
|
|
|
68
105
|
def nats_servers
|
|
@@ -75,6 +112,30 @@ module JetstreamBridge
|
|
|
75
112
|
|
|
76
113
|
def establish_connection(servers)
|
|
77
114
|
@nc = NATS::IO::Client.new
|
|
115
|
+
|
|
116
|
+
# Setup reconnect handler to refresh JetStream context
|
|
117
|
+
@nc.on_reconnect do
|
|
118
|
+
Logging.info(
|
|
119
|
+
'NATS reconnected, refreshing JetStream context',
|
|
120
|
+
tag: 'JetstreamBridge::Connection'
|
|
121
|
+
)
|
|
122
|
+
refresh_jetstream_context
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@nc.on_disconnect do |reason|
|
|
126
|
+
Logging.warn(
|
|
127
|
+
"NATS disconnected: #{reason}",
|
|
128
|
+
tag: 'JetstreamBridge::Connection'
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
@nc.on_error do |err|
|
|
133
|
+
Logging.error(
|
|
134
|
+
"NATS error: #{err}",
|
|
135
|
+
tag: 'JetstreamBridge::Connection'
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
78
139
|
@nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
|
|
79
140
|
|
|
80
141
|
# Create JetStream context
|
|
@@ -87,6 +148,20 @@ module JetstreamBridge
|
|
|
87
148
|
@jts.define_singleton_method(:nc) { nc_ref }
|
|
88
149
|
end
|
|
89
150
|
|
|
151
|
+
def refresh_jetstream_context
|
|
152
|
+
@jts = @nc.jetstream
|
|
153
|
+
nc_ref = @nc
|
|
154
|
+
@jts.define_singleton_method(:nc) { nc_ref } unless @jts.respond_to?(:nc)
|
|
155
|
+
|
|
156
|
+
# Re-ensure topology after reconnect
|
|
157
|
+
Topology.ensure!(@jts)
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
Logging.error(
|
|
160
|
+
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
161
|
+
tag: 'JetstreamBridge::Connection'
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
90
165
|
# Expose for class-level helpers (not part of public API)
|
|
91
166
|
attr_reader :nc
|
|
92
167
|
|