jetstream_bridge 2.6.0 → 2.7.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 +61 -25
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +8 -8
- 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/subscription_manager.rb +2 -2
- data/lib/jetstream_bridge/inbox_event.rb +2 -2
- data/lib/jetstream_bridge/outbox_event.rb +2 -2
- data/lib/jetstream_bridge/topology/subject_matcher.rb +6 -5
- data/lib/jetstream_bridge/version.rb +1 -1
- 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: fc39ce8a4d26a2488bcd9bb756a6380d3a5336e2048c2bb5b18082b4e7930e5d
|
4
|
+
data.tar.gz: f9c0298070c6a25771d8bf9848582bf256d1b7890e7d4944ad3653eead18a96f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c1b927989ccca2615e3e5229ac5679f9e4bfe76ec92d978cafe6cb1c4dcae40ccbecf4d88699020e9c4cebb695a6de7efedd8bfef259053c901db336181ac294
|
7
|
+
data.tar.gz: 51caa80c8790f79ea2808ab0f85de484b6b17626a08eaf06eb674340a4cead39d8016d2f971b97508e1be45e661490b555360378fcadf7b5c3f407bf30c4453f
|
@@ -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
|
@@ -7,17 +7,17 @@ module JetstreamBridge
|
|
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
22
|
JSON.parse(raw)
|
23
23
|
rescue StandardError
|
@@ -27,11 +27,11 @@ module JetstreamBridge
|
|
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
|
@@ -86,8 +86,8 @@ module JetstreamBridge
|
|
86
86
|
filter_subject: sval(cfg, :filter_subject), # string
|
87
87
|
ack_policy: sval(cfg, :ack_policy), # string
|
88
88
|
deliver_policy: sval(cfg, :deliver_policy), # string
|
89
|
-
max_deliver: ival(cfg, :max_deliver),
|
90
|
-
|
89
|
+
max_deliver: ival(cfg, :max_deliver), # integer
|
90
|
+
ack_wait: d_ms(cfg, :ack_wait), # integer ms
|
91
91
|
backoff_ms: darr_ms(cfg, :backoff) # array of integer ms
|
92
92
|
}
|
93
93
|
end
|
@@ -110,8 +110,8 @@ module JetstreamBridge
|
|
110
110
|
def raise_missing_ar!(which, method_name)
|
111
111
|
raise(
|
112
112
|
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
113
|
-
|
114
|
-
|
113
|
+
"Enable `use_inbox` only in apps with ActiveRecord, or add " \
|
114
|
+
"`gem \"activerecord\"` to your Gemfile."
|
115
115
|
)
|
116
116
|
end
|
117
117
|
end
|
@@ -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
|
@@ -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
|
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.7.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.
|