jetstream_bridge 2.6.0 → 2.8.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/lib/jetstream_bridge/consumer/consumer.rb +62 -26
- data/lib/jetstream_bridge/consumer/dlq_publisher.rb +2 -2
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +11 -11
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +8 -8
- data/lib/jetstream_bridge/consumer/message_context.rb +5 -5
- data/lib/jetstream_bridge/consumer/message_processor.rb +3 -7
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +10 -15
- data/lib/jetstream_bridge/core/connection.rb +1 -1
- data/lib/jetstream_bridge/core/duration.rb +10 -5
- data/lib/jetstream_bridge/core/model_codec_setup.rb +3 -1
- data/lib/jetstream_bridge/core/model_utils.rb +9 -4
- data/lib/jetstream_bridge/inbox_event.rb +6 -4
- data/lib/jetstream_bridge/outbox_event.rb +5 -5
- data/lib/jetstream_bridge/publisher/publisher.rb +16 -7
- data/lib/jetstream_bridge/topology/overlap_guard.rb +3 -3
- data/lib/jetstream_bridge/topology/subject_matcher.rb +6 -5
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +12 -2
- metadata +3 -87
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: eeebee439d30bce4eac88df3c7cbc09c2e0af258a071bbd053c0fd10f244c995
|
4
|
+
data.tar.gz: de810b9580efcc26b8cb44810994f64e7ae68548c2bf59b01f8a676db8e6d131
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0c28944d07ee1399425fb194f4fe58e2ae6006c34f722091f2b88fb959f1012b83008c26d397385eff1d61a2a5df54f85975208ec882bb63f28ef51f77d90f02
|
7
|
+
data.tar.gz: 7fdefcc529051eadcccc4d536adc71b7da61c3928adf17ccc7c76c73d9b9c7735bd30626e3a779264a40cd2d5e36044de08075124c16b68bcff01ae302c276c8
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
require 'securerandom'
|
5
5
|
require_relative '../core/connection'
|
6
6
|
require_relative '../core/duration'
|
@@ -15,35 +15,47 @@ require_relative 'inbox/inbox_processor'
|
|
15
15
|
module JetstreamBridge
|
16
16
|
# Subscribes to destination subject and processes messages via a pull durable.
|
17
17
|
class Consumer
|
18
|
-
DEFAULT_BATCH_SIZE
|
19
|
-
FETCH_TIMEOUT_SECS
|
20
|
-
IDLE_SLEEP_SECS
|
18
|
+
DEFAULT_BATCH_SIZE = 25
|
19
|
+
FETCH_TIMEOUT_SECS = 5
|
20
|
+
IDLE_SLEEP_SECS = 0.05
|
21
|
+
MAX_IDLE_BACKOFF_SECS = 1.0
|
21
22
|
|
22
23
|
def initialize(durable_name: JetstreamBridge.config.durable_name,
|
23
24
|
batch_size: DEFAULT_BATCH_SIZE, &block)
|
24
|
-
|
25
|
-
|
26
|
-
@
|
27
|
-
@
|
25
|
+
raise ArgumentError, 'handler block required' unless block_given?
|
26
|
+
|
27
|
+
@handler = block
|
28
|
+
@batch_size = Integer(batch_size)
|
29
|
+
@durable = durable_name
|
30
|
+
@idle_backoff = IDLE_SLEEP_SECS
|
31
|
+
@running = true
|
32
|
+
@jts = Connection.connect!
|
28
33
|
|
29
34
|
ensure_destination!
|
30
35
|
|
31
36
|
@sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
|
32
|
-
@sub_mgr.ensure_consumer!
|
33
|
-
@psub = @sub_mgr.subscribe!
|
34
|
-
|
35
37
|
@processor = MessageProcessor.new(@jts, @handler)
|
36
38
|
@inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
|
39
|
+
|
40
|
+
ensure_subscription!
|
37
41
|
end
|
38
42
|
|
39
43
|
def run!
|
40
|
-
Logging.info(
|
41
|
-
|
44
|
+
Logging.info(
|
45
|
+
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{JetstreamBridge.config.destination_subject})…",
|
46
|
+
tag: 'JetstreamBridge::Consumer'
|
47
|
+
)
|
48
|
+
while @running
|
42
49
|
processed = process_batch
|
43
|
-
|
50
|
+
idle_sleep(processed)
|
44
51
|
end
|
45
52
|
end
|
46
53
|
|
54
|
+
# Allow external callers to stop a long-running loop gracefully.
|
55
|
+
def stop!
|
56
|
+
@running = false
|
57
|
+
end
|
58
|
+
|
47
59
|
private
|
48
60
|
|
49
61
|
def ensure_destination!
|
@@ -52,14 +64,24 @@ module JetstreamBridge
|
|
52
64
|
raise ArgumentError, 'destination_app must be configured'
|
53
65
|
end
|
54
66
|
|
67
|
+
def ensure_subscription!
|
68
|
+
@sub_mgr.ensure_consumer!
|
69
|
+
@psub = @sub_mgr.subscribe!
|
70
|
+
end
|
71
|
+
|
55
72
|
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
56
73
|
def process_batch
|
57
74
|
msgs = fetch_messages
|
58
|
-
|
75
|
+
return 0 if msgs.nil? || msgs.empty?
|
76
|
+
|
77
|
+
msgs.sum { |m| process_one(m) }
|
59
78
|
rescue NATS::Timeout, NATS::IO::Timeout
|
60
79
|
0
|
61
80
|
rescue NATS::JetStream::Error => e
|
62
81
|
handle_js_error(e)
|
82
|
+
rescue StandardError => e
|
83
|
+
Logging.error("Unexpected process_batch error: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
84
|
+
0
|
63
85
|
end
|
64
86
|
|
65
87
|
# --- helpers ---
|
@@ -68,10 +90,6 @@ module JetstreamBridge
|
|
68
90
|
@psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
|
69
91
|
end
|
70
92
|
|
71
|
-
def process_messages(msgs)
|
72
|
-
msgs.sum { |m| process_one(m) }
|
73
|
-
end
|
74
|
-
|
75
93
|
def process_one(msg)
|
76
94
|
if @inbox_proc
|
77
95
|
@inbox_proc.process(msg) ? 1 : 0
|
@@ -79,6 +97,10 @@ module JetstreamBridge
|
|
79
97
|
@processor.handle_message(msg)
|
80
98
|
1
|
81
99
|
end
|
100
|
+
rescue StandardError => e
|
101
|
+
# Safety: never let a single bad message kill the batch loop.
|
102
|
+
Logging.error("Message processing crashed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
103
|
+
0
|
82
104
|
end
|
83
105
|
|
84
106
|
def handle_js_error(e)
|
@@ -87,22 +109,36 @@ module JetstreamBridge
|
|
87
109
|
"Recovering subscription after error: #{e.class} #{e.message}",
|
88
110
|
tag: 'JetstreamBridge::Consumer'
|
89
111
|
)
|
90
|
-
|
91
|
-
@psub = @sub_mgr.subscribe!
|
112
|
+
ensure_subscription!
|
92
113
|
else
|
93
|
-
Logging.error(
|
94
|
-
"Fetch failed: #{e.class} #{e.message}",
|
95
|
-
tag: 'JetstreamBridge::Consumer'
|
96
|
-
)
|
114
|
+
Logging.error("Fetch failed (non-recoverable): #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
97
115
|
end
|
98
116
|
0
|
99
117
|
end
|
100
118
|
|
101
119
|
def recoverable_consumer_error?(error)
|
102
120
|
msg = error.message.to_s
|
121
|
+
code = js_err_code(msg)
|
122
|
+
# Heuristics: consumer/stream missing, no responders, or common 404-ish cases
|
103
123
|
msg =~ /consumer.*(not\s+found|deleted)/i ||
|
104
124
|
msg =~ /no\s+responders/i ||
|
105
|
-
msg =~ /stream.*not\s+found/i
|
125
|
+
msg =~ /stream.*not\s+found/i ||
|
126
|
+
code == 404
|
127
|
+
end
|
128
|
+
|
129
|
+
def js_err_code(message)
|
130
|
+
m = message.match(/err_code=(\d{3,5})/)
|
131
|
+
m ? m[1].to_i : nil
|
132
|
+
end
|
133
|
+
|
134
|
+
def idle_sleep(processed)
|
135
|
+
if processed.zero?
|
136
|
+
# exponential-ish backoff with a tiny jitter to avoid sync across workers
|
137
|
+
@idle_backoff = [@idle_backoff * 1.5, MAX_IDLE_BACKOFF_SECS].min
|
138
|
+
sleep(@idle_backoff + (rand * 0.01))
|
139
|
+
else
|
140
|
+
@idle_backoff = IDLE_SLEEP_SECS
|
141
|
+
end
|
106
142
|
end
|
107
143
|
end
|
108
144
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
require 'time'
|
5
5
|
require_relative '../core/logging'
|
6
6
|
|
@@ -46,7 +46,7 @@ module JetstreamBridge
|
|
46
46
|
headers['x-dead-letter'] = 'true'
|
47
47
|
headers['x-dlq-reason'] = reason
|
48
48
|
headers['x-deliveries'] = deliveries.to_s
|
49
|
-
headers['x-dlq-context'] =
|
49
|
+
headers['x-dlq-context'] = Oj.dump(envelope, mode: :compat)
|
50
50
|
headers
|
51
51
|
end
|
52
52
|
end
|
@@ -1,37 +1,37 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
|
5
5
|
module JetstreamBridge
|
6
6
|
# Immutable value object for a single NATS message.
|
7
7
|
class InboxMessage
|
8
8
|
attr_reader :msg, :seq, :deliveries, :stream, :subject, :headers, :body, :raw, :event_id, :now
|
9
9
|
|
10
|
-
def self.from_nats(
|
11
|
-
meta = (
|
10
|
+
def self.from_nats(msg)
|
11
|
+
meta = (msg.respond_to?(:metadata) && msg.metadata) || nil
|
12
12
|
seq = meta.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
|
13
13
|
deliveries = meta.respond_to?(:num_delivered) ? meta.num_delivered : nil
|
14
14
|
stream = meta.respond_to?(:stream) ? meta.stream : nil
|
15
|
-
subject =
|
15
|
+
subject = msg.subject.to_s
|
16
16
|
|
17
17
|
headers = {}
|
18
|
-
(
|
18
|
+
(msg.header || {}).each { |k, v| headers[k.to_s.downcase] = v }
|
19
19
|
|
20
|
-
raw =
|
20
|
+
raw = msg.data
|
21
21
|
body = begin
|
22
|
-
|
23
|
-
rescue
|
22
|
+
Oj.load(raw, mode: :strict)
|
23
|
+
rescue Oj::Error
|
24
24
|
{}
|
25
25
|
end
|
26
26
|
|
27
27
|
id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
|
28
28
|
id = "seq:#{seq}" if id.empty?
|
29
29
|
|
30
|
-
new(
|
30
|
+
new(msg, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc)
|
31
31
|
end
|
32
32
|
|
33
|
-
def initialize(
|
34
|
-
@msg =
|
33
|
+
def initialize(msg, seq, deliveries, stream, subject, headers, body, raw, event_id, now)
|
34
|
+
@msg = msg
|
35
35
|
@seq = seq
|
36
36
|
@deliveries = deliveries
|
37
37
|
@stream = stream
|
@@ -13,24 +13,24 @@ module JetstreamBridge
|
|
13
13
|
end
|
14
14
|
|
15
15
|
# @return [true,false] processed?
|
16
|
-
def process(
|
16
|
+
def process(msg)
|
17
17
|
klass = ModelUtils.constantize(JetstreamBridge.config.inbox_model)
|
18
|
-
return process_direct(
|
18
|
+
return process_direct?(msg, klass) unless ModelUtils.ar_class?(klass)
|
19
19
|
|
20
|
-
msg = InboxMessage.from_nats(
|
20
|
+
msg = InboxMessage.from_nats(msg)
|
21
21
|
repo = InboxRepository.new(klass)
|
22
22
|
record = repo.find_or_build(msg)
|
23
23
|
|
24
24
|
if repo.already_processed?(record)
|
25
|
-
|
25
|
+
msg.ack
|
26
26
|
return true
|
27
27
|
end
|
28
28
|
|
29
29
|
repo.persist_pre(record, msg)
|
30
|
-
@processor.handle_message(
|
30
|
+
@processor.handle_message(msg)
|
31
31
|
repo.persist_post(record)
|
32
32
|
true
|
33
|
-
rescue => e
|
33
|
+
rescue StandardError => e
|
34
34
|
repo.persist_failure(record, e) if defined?(repo) && defined?(record)
|
35
35
|
Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
|
36
36
|
tag: 'JetstreamBridge::Consumer')
|
@@ -39,12 +39,12 @@ module JetstreamBridge
|
|
39
39
|
|
40
40
|
private
|
41
41
|
|
42
|
-
def process_direct(
|
42
|
+
def process_direct?(msg, klass)
|
43
43
|
unless ModelUtils.ar_class?(klass)
|
44
44
|
Logging.warn("Inbox model #{klass} is not an ActiveRecord model; processing directly.",
|
45
45
|
tag: 'JetstreamBridge::Consumer')
|
46
46
|
end
|
47
|
-
@processor.handle_message(
|
47
|
+
@processor.handle_message(msg)
|
48
48
|
true
|
49
49
|
end
|
50
50
|
end
|
@@ -10,12 +10,12 @@ module JetstreamBridge
|
|
10
10
|
) do
|
11
11
|
def self.build(msg)
|
12
12
|
new(
|
13
|
-
event_id:
|
13
|
+
event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
|
14
14
|
deliveries: msg.metadata&.num_delivered.to_i,
|
15
|
-
subject:
|
16
|
-
seq:
|
17
|
-
consumer:
|
18
|
-
stream:
|
15
|
+
subject: msg.subject,
|
16
|
+
seq: msg.metadata&.sequence,
|
17
|
+
consumer: msg.metadata&.consumer,
|
18
|
+
stream: msg.metadata&.stream
|
19
19
|
)
|
20
20
|
end
|
21
21
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
require_relative '../core/logging'
|
5
5
|
require_relative 'message_context'
|
6
6
|
require_relative 'dlq_publisher'
|
@@ -37,12 +37,8 @@ module JetstreamBridge
|
|
37
37
|
|
38
38
|
def parse_message(msg, ctx)
|
39
39
|
data = msg.data
|
40
|
-
|
41
|
-
|
42
|
-
else
|
43
|
-
JSON.parse(data)
|
44
|
-
end
|
45
|
-
rescue JSON::ParserError => e
|
40
|
+
Oj.load(data, mode: :strict)
|
41
|
+
rescue Oj::ParseError => e
|
46
42
|
@dlq.publish(msg, ctx,
|
47
43
|
reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
|
48
44
|
msg.ack
|
@@ -11,6 +11,8 @@ module JetstreamBridge
|
|
11
11
|
@jts = jts
|
12
12
|
@durable = durable
|
13
13
|
@cfg = cfg
|
14
|
+
@desired_cfg = ConsumerConfig.consumer_config(@durable, filter_subject)
|
15
|
+
@desired_cfg_norm = normalize_consumer_config(@desired_cfg)
|
14
16
|
end
|
15
17
|
|
16
18
|
def stream_name
|
@@ -22,18 +24,18 @@ module JetstreamBridge
|
|
22
24
|
end
|
23
25
|
|
24
26
|
def desired_consumer_cfg
|
25
|
-
|
27
|
+
@desired_cfg
|
26
28
|
end
|
27
29
|
|
28
30
|
def ensure_consumer!
|
29
31
|
info = consumer_info_or_nil
|
30
32
|
return create_consumer! unless info
|
31
33
|
|
32
|
-
|
33
|
-
if
|
34
|
+
have_norm = normalize_consumer_config(info.config)
|
35
|
+
if have_norm == @desired_cfg_norm
|
34
36
|
log_consumer_ok
|
35
37
|
else
|
36
|
-
log_consumer_diff(
|
38
|
+
log_consumer_diff(have_norm)
|
37
39
|
recreate_consumer!
|
38
40
|
end
|
39
41
|
end
|
@@ -58,15 +60,8 @@ module JetstreamBridge
|
|
58
60
|
|
59
61
|
# ---- comparison ----
|
60
62
|
|
61
|
-
def
|
62
|
-
|
63
|
-
want_norm = normalize_consumer_config(want)
|
64
|
-
have_norm == want_norm
|
65
|
-
end
|
66
|
-
|
67
|
-
def log_consumer_diff(info, want)
|
68
|
-
have_norm = normalize_consumer_config(info.config)
|
69
|
-
want_norm = normalize_consumer_config(want)
|
63
|
+
def log_consumer_diff(have_norm)
|
64
|
+
want_norm = @desired_cfg_norm
|
70
65
|
|
71
66
|
diffs = {}
|
72
67
|
(have_norm.keys | want_norm.keys).each do |k|
|
@@ -86,8 +81,8 @@ module JetstreamBridge
|
|
86
81
|
filter_subject: sval(cfg, :filter_subject), # string
|
87
82
|
ack_policy: sval(cfg, :ack_policy), # string
|
88
83
|
deliver_policy: sval(cfg, :deliver_policy), # string
|
89
|
-
max_deliver: ival(cfg, :max_deliver),
|
90
|
-
|
84
|
+
max_deliver: ival(cfg, :max_deliver), # integer
|
85
|
+
ack_wait: d_ms(cfg, :ack_wait), # integer ms
|
91
86
|
backoff_ms: darr_ms(cfg, :backoff) # array of integer ms
|
92
87
|
}
|
93
88
|
end
|
@@ -14,6 +14,7 @@ module JetstreamBridge
|
|
14
14
|
# Examples:
|
15
15
|
# Duration.to_millis(30) #=> 30000 (auto)
|
16
16
|
# Duration.to_millis(1500) #=> 1500 (auto)
|
17
|
+
# Duration.to_millis("1500") #=> 1500 (auto)
|
17
18
|
# Duration.to_millis(1500, default_unit: :s) #=> 1_500_000
|
18
19
|
# Duration.to_millis("30s") #=> 30000
|
19
20
|
# Duration.to_millis("500ms") #=> 500
|
@@ -48,7 +49,7 @@ module JetstreamBridge
|
|
48
49
|
case val
|
49
50
|
when Integer then int_to_ms(val, default_unit: default_unit)
|
50
51
|
when Float then float_to_ms(val, default_unit: default_unit)
|
51
|
-
when String then string_to_ms(val, default_unit: default_unit
|
52
|
+
when String then string_to_ms(val, default_unit: default_unit)
|
52
53
|
else
|
53
54
|
raise ArgumentError, "invalid duration type: #{val.class}" unless val.respond_to?(:to_f)
|
54
55
|
|
@@ -59,7 +60,10 @@ module JetstreamBridge
|
|
59
60
|
|
60
61
|
# Normalize an array of durations into integer milliseconds.
|
61
62
|
def normalize_list_to_millis(values, default_unit: :auto)
|
62
|
-
Array(values)
|
63
|
+
vals = Array(values)
|
64
|
+
return [] if vals.empty?
|
65
|
+
|
66
|
+
vals.map { |v| to_millis(v, default_unit: default_unit) }
|
63
67
|
end
|
64
68
|
|
65
69
|
# --- internal helpers ---
|
@@ -70,7 +74,7 @@ module JetstreamBridge
|
|
70
74
|
# Preserve existing heuristic for compatibility
|
71
75
|
num >= 1_000 ? num : num * 1_000
|
72
76
|
else
|
73
|
-
coerce_numeric_to_ms(
|
77
|
+
coerce_numeric_to_ms(num.to_f, default_unit)
|
74
78
|
end
|
75
79
|
end
|
76
80
|
|
@@ -80,8 +84,9 @@ module JetstreamBridge
|
|
80
84
|
|
81
85
|
def string_to_ms(str, default_unit:)
|
82
86
|
s = str.strip
|
83
|
-
# Plain number
|
84
|
-
|
87
|
+
# Plain number strings are treated like integers so the :auto
|
88
|
+
# heuristic still applies (<1000 => seconds, >=1000 => ms).
|
89
|
+
return int_to_ms(s.delete('_').to_i, default_unit: default_unit) if NUMBER_RE.match?(s)
|
85
90
|
|
86
91
|
m = TOKEN_RE.match(s)
|
87
92
|
raise ArgumentError, "invalid duration: #{str.inspect}" unless m
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'oj'
|
4
|
+
|
3
5
|
module JetstreamBridge
|
4
6
|
module ModelCodecSetup
|
5
7
|
module_function
|
@@ -17,7 +19,7 @@ module JetstreamBridge
|
|
17
19
|
next unless column?(klass, attr)
|
18
20
|
next if json_column?(klass, attr) || already_serialized?(klass, attr)
|
19
21
|
|
20
|
-
klass.serialize attr.to_sym, coder:
|
22
|
+
klass.serialize attr.to_sym, coder: Oj
|
21
23
|
end
|
22
24
|
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
23
25
|
# ignore when schema isn’t available yet
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'oj'
|
4
|
+
|
3
5
|
module JetstreamBridge
|
4
6
|
module ModelUtils
|
5
7
|
module_function
|
@@ -36,15 +38,18 @@ module JetstreamBridge
|
|
36
38
|
end
|
37
39
|
|
38
40
|
def json_dump(obj)
|
39
|
-
obj.is_a?(String)
|
40
|
-
|
41
|
+
return obj if obj.is_a?(String)
|
42
|
+
|
43
|
+
Oj.dump(obj, mode: :compat)
|
44
|
+
rescue Oj::Error, TypeError
|
41
45
|
obj.to_s
|
42
46
|
end
|
43
47
|
|
44
48
|
def json_load(str)
|
45
49
|
return str if str.is_a?(Hash)
|
46
|
-
|
47
|
-
|
50
|
+
|
51
|
+
Oj.load(str.to_s, mode: :strict)
|
52
|
+
rescue Oj::Error
|
48
53
|
{}
|
49
54
|
end
|
50
55
|
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'oj'
|
4
|
+
|
3
5
|
begin
|
4
6
|
require 'active_record'
|
5
7
|
rescue LoadError
|
@@ -84,8 +86,8 @@ module JetstreamBridge
|
|
84
86
|
v = self[:payload]
|
85
87
|
case v
|
86
88
|
when String then begin
|
87
|
-
|
88
|
-
rescue
|
89
|
+
Oj.load(v, mode: :strict)
|
90
|
+
rescue Oj::Error
|
89
91
|
{}
|
90
92
|
end
|
91
93
|
when Hash then v
|
@@ -110,8 +112,8 @@ module JetstreamBridge
|
|
110
112
|
def raise_missing_ar!(which, method_name)
|
111
113
|
raise(
|
112
114
|
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
113
|
-
|
114
|
-
|
115
|
+
"Enable `use_inbox` only in apps with ActiveRecord, or add " \
|
116
|
+
"`gem \"activerecord\"` to your Gemfile."
|
115
117
|
)
|
116
118
|
end
|
117
119
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
|
5
5
|
begin
|
6
6
|
require 'active_record'
|
@@ -93,8 +93,8 @@ module JetstreamBridge
|
|
93
93
|
v = self[:payload]
|
94
94
|
case v
|
95
95
|
when String then begin
|
96
|
-
|
97
|
-
rescue
|
96
|
+
Oj.load(v, mode: :strict)
|
97
|
+
rescue Oj::Error
|
98
98
|
{}
|
99
99
|
end
|
100
100
|
when Hash then v
|
@@ -120,8 +120,8 @@ module JetstreamBridge
|
|
120
120
|
def raise_missing_ar!(which, method_name)
|
121
121
|
raise(
|
122
122
|
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
123
|
-
|
124
|
-
|
123
|
+
"Enable `use_outbox` only in apps with ActiveRecord, or add " \
|
124
|
+
"`gem \"activerecord\"` to your Gemfile."
|
125
125
|
)
|
126
126
|
end
|
127
127
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
require 'securerandom'
|
5
5
|
require_relative '../core/connection'
|
6
6
|
require_relative '../core/logging'
|
@@ -50,12 +50,21 @@ module JetstreamBridge
|
|
50
50
|
def do_publish?(subject, envelope)
|
51
51
|
headers = { 'nats-msg-id' => envelope['event_id'] }
|
52
52
|
|
53
|
-
@jts.publish(subject,
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
53
|
+
ack = @jts.publish(subject, Oj.dump(envelope, mode: :compat), header: headers)
|
54
|
+
duplicate = ack.respond_to?(:duplicate?) && ack.duplicate?
|
55
|
+
msg = "Published #{subject} event_id=#{envelope['event_id']}"
|
56
|
+
msg += ' (duplicate)' if duplicate
|
57
|
+
|
58
|
+
Logging.info(msg, tag: 'JetstreamBridge::Publisher')
|
59
|
+
|
60
|
+
if ack.respond_to?(:error) && ack.error
|
61
|
+
Logging.error(
|
62
|
+
"Publish ack error: #{ack.error}",
|
63
|
+
tag: 'JetstreamBridge::Publisher'
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
!ack.respond_to?(:error) || ack.error.nil?
|
59
68
|
end
|
60
69
|
|
61
70
|
# ---- Outbox path ----
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
require_relative 'subject_matcher'
|
5
5
|
require_relative '../core/logging'
|
6
6
|
|
@@ -68,8 +68,8 @@ module JetstreamBridge
|
|
68
68
|
|
69
69
|
def js_api_request(jts, subject, payload = {})
|
70
70
|
# JetStream client should expose the underlying NATS client as `nc`
|
71
|
-
msg = jts.nc.request(subject,
|
72
|
-
|
71
|
+
msg = jts.nc.request(subject, Oj.dump(payload, mode: :compat))
|
72
|
+
Oj.load(msg.data, mode: :strict)
|
73
73
|
end
|
74
74
|
|
75
75
|
def conflict_message(target, conflicts)
|
@@ -34,12 +34,12 @@ module JetstreamBridge
|
|
34
34
|
return true if i == p.length && i == s.length
|
35
35
|
|
36
36
|
# If pattern has remaining '>' it can absorb remainder
|
37
|
-
p[i] == '>' || p[i
|
37
|
+
p[i] == '>' || p[i..]&.include?('>')
|
38
38
|
end
|
39
39
|
|
40
40
|
# Do two wildcard patterns admit at least one same subject?
|
41
|
-
def overlap?(
|
42
|
-
overlap_parts?(
|
41
|
+
def overlap?(sub_a, sub_b)
|
42
|
+
overlap_parts?(sub_a.split('.'), sub_b.split('.'))
|
43
43
|
end
|
44
44
|
|
45
45
|
def overlap_parts?(a_parts, b_parts)
|
@@ -50,13 +50,14 @@ module JetstreamBridge
|
|
50
50
|
bt = b_parts[bi]
|
51
51
|
return true if at == '>' || bt == '>'
|
52
52
|
return false unless at == bt || at == '*' || bt == '*'
|
53
|
+
|
53
54
|
ai += 1
|
54
55
|
bi += 1
|
55
56
|
end
|
56
57
|
|
57
58
|
# If any side still has a '>' remaining, it can absorb the other's remainder
|
58
|
-
a_tail = a_parts[ai
|
59
|
-
b_tail = b_parts[bi
|
59
|
+
a_tail = a_parts[ai..] || []
|
60
|
+
b_tail = b_parts[bi..] || []
|
60
61
|
return true if a_tail.include?('>') || b_tail.include?('>')
|
61
62
|
|
62
63
|
# Otherwise they overlap only if both consumed exactly
|
data/lib/jetstream_bridge.rb
CHANGED
@@ -46,9 +46,19 @@ module JetstreamBridge
|
|
46
46
|
config.use_dlq
|
47
47
|
end
|
48
48
|
|
49
|
-
|
49
|
+
# Establishes a connection and ensures stream topology.
|
50
|
+
#
|
51
|
+
# @return [Object] JetStream context
|
52
|
+
def ensure_topology!
|
50
53
|
Connection.connect!
|
51
|
-
|
54
|
+
Connection.jetstream
|
55
|
+
end
|
56
|
+
|
57
|
+
# @deprecated Use {ensure_topology!} instead. This method will be removed
|
58
|
+
# in a future version.
|
59
|
+
def ensure_topology?
|
60
|
+
Logging.warn('ensure_topology? is deprecated; use ensure_topology! instead', tag: 'JetstreamBridge')
|
61
|
+
!!ensure_topology!
|
52
62
|
end
|
53
63
|
|
54
64
|
private
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jetstream_bridge
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Attara
|
@@ -11,7 +11,7 @@ cert_chain: []
|
|
11
11
|
date: 2025-08-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: activerecord
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
@@ -25,7 +25,7 @@ dependencies:
|
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '6.0'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: activesupport
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - ">="
|
@@ -66,90 +66,6 @@ dependencies:
|
|
66
66
|
- - ">="
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '3.16'
|
69
|
-
- !ruby/object:Gem::Dependency
|
70
|
-
name: rake
|
71
|
-
requirement: !ruby/object:Gem::Requirement
|
72
|
-
requirements:
|
73
|
-
- - ">="
|
74
|
-
- !ruby/object:Gem::Version
|
75
|
-
version: '13.0'
|
76
|
-
type: :development
|
77
|
-
prerelease: false
|
78
|
-
version_requirements: !ruby/object:Gem::Requirement
|
79
|
-
requirements:
|
80
|
-
- - ">="
|
81
|
-
- !ruby/object:Gem::Version
|
82
|
-
version: '13.0'
|
83
|
-
- !ruby/object:Gem::Dependency
|
84
|
-
name: rspec
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
86
|
-
requirements:
|
87
|
-
- - ">="
|
88
|
-
- !ruby/object:Gem::Version
|
89
|
-
version: '3.12'
|
90
|
-
type: :development
|
91
|
-
prerelease: false
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
93
|
-
requirements:
|
94
|
-
- - ">="
|
95
|
-
- !ruby/object:Gem::Version
|
96
|
-
version: '3.12'
|
97
|
-
- !ruby/object:Gem::Dependency
|
98
|
-
name: rubocop
|
99
|
-
requirement: !ruby/object:Gem::Requirement
|
100
|
-
requirements:
|
101
|
-
- - "~>"
|
102
|
-
- !ruby/object:Gem::Version
|
103
|
-
version: '1.66'
|
104
|
-
type: :development
|
105
|
-
prerelease: false
|
106
|
-
version_requirements: !ruby/object:Gem::Requirement
|
107
|
-
requirements:
|
108
|
-
- - "~>"
|
109
|
-
- !ruby/object:Gem::Version
|
110
|
-
version: '1.66'
|
111
|
-
- !ruby/object:Gem::Dependency
|
112
|
-
name: rubocop-packaging
|
113
|
-
requirement: !ruby/object:Gem::Requirement
|
114
|
-
requirements:
|
115
|
-
- - "~>"
|
116
|
-
- !ruby/object:Gem::Version
|
117
|
-
version: '0.5'
|
118
|
-
type: :development
|
119
|
-
prerelease: false
|
120
|
-
version_requirements: !ruby/object:Gem::Requirement
|
121
|
-
requirements:
|
122
|
-
- - "~>"
|
123
|
-
- !ruby/object:Gem::Version
|
124
|
-
version: '0.5'
|
125
|
-
- !ruby/object:Gem::Dependency
|
126
|
-
name: rubocop-performance
|
127
|
-
requirement: !ruby/object:Gem::Requirement
|
128
|
-
requirements:
|
129
|
-
- - "~>"
|
130
|
-
- !ruby/object:Gem::Version
|
131
|
-
version: '1.21'
|
132
|
-
type: :development
|
133
|
-
prerelease: false
|
134
|
-
version_requirements: !ruby/object:Gem::Requirement
|
135
|
-
requirements:
|
136
|
-
- - "~>"
|
137
|
-
- !ruby/object:Gem::Version
|
138
|
-
version: '1.21'
|
139
|
-
- !ruby/object:Gem::Dependency
|
140
|
-
name: bundler-audit
|
141
|
-
requirement: !ruby/object:Gem::Requirement
|
142
|
-
requirements:
|
143
|
-
- - ">="
|
144
|
-
- !ruby/object:Gem::Version
|
145
|
-
version: 0.9.1
|
146
|
-
type: :development
|
147
|
-
prerelease: false
|
148
|
-
version_requirements: !ruby/object:Gem::Requirement
|
149
|
-
requirements:
|
150
|
-
- - ">="
|
151
|
-
- !ruby/object:Gem::Version
|
152
|
-
version: 0.9.1
|
153
69
|
description: |-
|
154
70
|
Publisher/Consumer utilities for NATS JetStream with environment-scoped subjects,
|
155
71
|
overlap guards, DLQ routing, retries/backoff, and optional Inbox/Outbox patterns.
|