jetstream_bridge 1.6.0 → 1.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/.idea/dictionaries/project.xml +1 -0
- data/.idea/jetstream_bridge.iml +6 -1
- data/.rubocop.yml +102 -0
- data/Gemfile.lock +1 -5
- data/README.md +76 -32
- data/jetstream_bridge.gemspec +9 -10
- data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +16 -0
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +24 -0
- data/lib/generators/jetstream_bridge/install/install_generator.rb +19 -0
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +44 -0
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +24 -0
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +21 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +103 -0
- data/lib/jetstream_bridge/{consumer_config.rb → consumer/consumer_config.rb} +3 -3
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +50 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +51 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +102 -0
- data/lib/jetstream_bridge/{message_processor.rb → consumer/message_processor.rb} +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +91 -0
- data/lib/jetstream_bridge/{connection.rb → core/connection.rb} +1 -1
- data/lib/jetstream_bridge/models/inbox_event.rb +98 -0
- data/lib/jetstream_bridge/models/outbox_event.rb +114 -0
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
- data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +10 -58
- data/lib/jetstream_bridge/railtie.rb +12 -0
- data/lib/jetstream_bridge/tasks/install.rake +10 -0
- data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
- data/lib/jetstream_bridge/topology/stream.rb +129 -0
- data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +35 -23
- metadata +49 -50
- data/lib/jetstream_bridge/consumer.rb +0 -232
- data/lib/jetstream_bridge/dlq.rb +0 -24
- data/lib/jetstream_bridge/inbox_event.rb +0 -46
- data/lib/jetstream_bridge/outbox_event.rb +0 -60
- data/lib/jetstream_bridge/stream.rb +0 -114
- /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
- /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
- /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
- /data/lib/jetstream_bridge/{model_utils.rb → core/model_utils.rb} +0 -0
- /data/lib/jetstream_bridge/{subject_matcher.rb → topology/subject_matcher.rb} +0 -0
@@ -1,232 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'json'
|
4
|
-
require 'securerandom'
|
5
|
-
require_relative 'connection'
|
6
|
-
require_relative 'duration'
|
7
|
-
require_relative 'logging'
|
8
|
-
require_relative 'consumer_config'
|
9
|
-
require_relative 'message_processor'
|
10
|
-
require_relative 'config'
|
11
|
-
require_relative 'model_utils'
|
12
|
-
|
13
|
-
module JetstreamBridge
|
14
|
-
# Subscribes to "{env}.data.sync.{dest}.{app}" and processes messages.
|
15
|
-
class Consumer
|
16
|
-
DEFAULT_BATCH_SIZE = 25
|
17
|
-
FETCH_TIMEOUT_SECS = 5
|
18
|
-
IDLE_SLEEP_SECS = 0.05
|
19
|
-
|
20
|
-
# @param durable_name [String] Consumer name
|
21
|
-
# @param batch_size [Integer] Max messages per fetch
|
22
|
-
# @yield [event, subject, deliveries] Message handler
|
23
|
-
def initialize(durable_name:, batch_size: DEFAULT_BATCH_SIZE, &block)
|
24
|
-
@handler = block
|
25
|
-
@batch_size = batch_size
|
26
|
-
@durable = durable_name
|
27
|
-
@jts = Connection.connect!
|
28
|
-
|
29
|
-
ensure_destination!
|
30
|
-
ensure_consumer!
|
31
|
-
subscribe!
|
32
|
-
@processor = MessageProcessor.new(@jts, @handler)
|
33
|
-
end
|
34
|
-
|
35
|
-
# Starts the consumer loop.
|
36
|
-
def run!
|
37
|
-
Logging.info("Consumer #{@durable} started…", tag: 'JetstreamBridge::Consumer')
|
38
|
-
loop do
|
39
|
-
processed = process_batch
|
40
|
-
sleep(IDLE_SLEEP_SECS) if processed.zero?
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
private
|
45
|
-
|
46
|
-
def ensure_destination!
|
47
|
-
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
48
|
-
raise ArgumentError, 'destination_app must be configured'
|
49
|
-
end
|
50
|
-
|
51
|
-
def stream_name
|
52
|
-
JetstreamBridge.config.stream_name
|
53
|
-
end
|
54
|
-
|
55
|
-
def filter_subject
|
56
|
-
JetstreamBridge.config.destination_subject
|
57
|
-
end
|
58
|
-
|
59
|
-
def desired_consumer_cfg
|
60
|
-
ConsumerConfig.consumer_config(@durable, filter_subject)
|
61
|
-
end
|
62
|
-
|
63
|
-
def ensure_consumer!
|
64
|
-
info = @jts.consumer_info(stream_name, @durable)
|
65
|
-
if consumer_mismatch?(info, desired_consumer_cfg)
|
66
|
-
Logging.warn(
|
67
|
-
"Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
|
68
|
-
tag: 'JetstreamBridge::Consumer'
|
69
|
-
)
|
70
|
-
begin
|
71
|
-
@jts.delete_consumer(stream_name, @durable)
|
72
|
-
rescue NATS::JetStream::Error => e
|
73
|
-
Logging.warn("Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
|
74
|
-
tag: 'JetstreamBridge::Consumer')
|
75
|
-
end
|
76
|
-
@jts.add_consumer(stream_name, **desired_consumer_cfg)
|
77
|
-
Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
|
78
|
-
tag: 'JetstreamBridge::Consumer')
|
79
|
-
else
|
80
|
-
Logging.info("Consumer #{@durable} exists with desired config.",
|
81
|
-
tag: 'JetstreamBridge::Consumer')
|
82
|
-
end
|
83
|
-
rescue NATS::JetStream::Error
|
84
|
-
@jts.add_consumer(stream_name, **desired_consumer_cfg)
|
85
|
-
Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
|
86
|
-
tag: 'JetstreamBridge::Consumer')
|
87
|
-
end
|
88
|
-
|
89
|
-
def consumer_mismatch?(info, desired_cfg)
|
90
|
-
cfg = info.config
|
91
|
-
(cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
|
92
|
-
desired_cfg[:filter_subject].to_s
|
93
|
-
end
|
94
|
-
|
95
|
-
def subscribe!
|
96
|
-
@psub = @jts.pull_subscribe(
|
97
|
-
filter_subject,
|
98
|
-
@durable,
|
99
|
-
stream: stream_name,
|
100
|
-
config: ConsumerConfig.subscribe_config
|
101
|
-
)
|
102
|
-
Logging.info("Subscribed to #{filter_subject} (durable=#{@durable})",
|
103
|
-
tag: 'JetstreamBridge::Consumer')
|
104
|
-
end
|
105
|
-
|
106
|
-
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
107
|
-
def process_batch
|
108
|
-
msgs = @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
|
109
|
-
count = 0
|
110
|
-
msgs.each do |m|
|
111
|
-
if JetstreamBridge.config.use_inbox
|
112
|
-
count += (process_with_inbox(m) ? 1 : 0)
|
113
|
-
else
|
114
|
-
@processor.handle_message(m)
|
115
|
-
count += 1
|
116
|
-
end
|
117
|
-
end
|
118
|
-
count
|
119
|
-
rescue NATS::Timeout, NATS::IO::Timeout
|
120
|
-
0
|
121
|
-
rescue NATS::JetStream::Error => e
|
122
|
-
if recoverable_consumer_error?(e)
|
123
|
-
Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
|
124
|
-
tag: 'JetstreamBridge::Consumer')
|
125
|
-
ensure_consumer!
|
126
|
-
subscribe!
|
127
|
-
0
|
128
|
-
else
|
129
|
-
Logging.error("Fetch failed: #{e.class} #{e.message}",
|
130
|
-
tag: 'JetstreamBridge::Consumer')
|
131
|
-
0
|
132
|
-
end
|
133
|
-
end
|
134
|
-
|
135
|
-
# ----- Inbox path -----
|
136
|
-
def process_with_inbox(m)
|
137
|
-
klass = ModelUtils.constantize(JetstreamBridge.config.inbox_model)
|
138
|
-
|
139
|
-
unless ModelUtils.ar_class?(klass)
|
140
|
-
Logging.warn("Inbox model #{klass} is not an ActiveRecord model; processing directly.",
|
141
|
-
tag: 'JetstreamBridge::Consumer')
|
142
|
-
@processor.handle_message(m)
|
143
|
-
return true
|
144
|
-
end
|
145
|
-
|
146
|
-
meta = (m.respond_to?(:metadata) && m.metadata) || nil
|
147
|
-
seq = meta&.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
|
148
|
-
deliveries= meta&.respond_to?(:num_delivered) ? meta.num_delivered : nil
|
149
|
-
subject = m.subject.to_s
|
150
|
-
headers = (m.header || {})
|
151
|
-
body_str = m.data
|
152
|
-
begin
|
153
|
-
body = JSON.parse(body_str)
|
154
|
-
rescue
|
155
|
-
body = {}
|
156
|
-
end
|
157
|
-
|
158
|
-
event_id = (headers['Nats-Msg-Id'] || body['event_id']).to_s.strip
|
159
|
-
now = Time.now.utc
|
160
|
-
|
161
|
-
# Prefer event_id; fallback to the stream sequence (and subject) if the schema differs
|
162
|
-
record = if ModelUtils.has_columns?(klass, :event_id)
|
163
|
-
klass.find_or_initialize_by(event_id: (event_id.presence || "seq:#{seq}"))
|
164
|
-
elsif ModelUtils.has_columns?(klass, :stream_seq)
|
165
|
-
klass.find_or_initialize_by(stream_seq: seq)
|
166
|
-
else
|
167
|
-
klass.new
|
168
|
-
end
|
169
|
-
|
170
|
-
# If already processed, just ACK and skip handler
|
171
|
-
if record.respond_to?(:processed_at) && record.processed_at
|
172
|
-
m.ack
|
173
|
-
return true
|
174
|
-
end
|
175
|
-
|
176
|
-
# Create/update the inbox row before processing (idempotency + audit)
|
177
|
-
ModelUtils.assign_known_attrs(record, {
|
178
|
-
event_id: (ModelUtils.has_columns?(klass, :event_id) ? (event_id.presence || "seq:#{seq}") : nil),
|
179
|
-
subject: subject,
|
180
|
-
payload: ModelUtils.json_dump(body.empty? ? body_str : body),
|
181
|
-
headers: ModelUtils.json_dump(headers),
|
182
|
-
stream: (ModelUtils.has_columns?(klass, :stream) ? meta&.stream : nil),
|
183
|
-
stream_seq: (ModelUtils.has_columns?(klass, :stream_seq) ? seq : nil),
|
184
|
-
deliveries: (ModelUtils.has_columns?(klass, :deliveries) ? deliveries : nil),
|
185
|
-
status: 'processing',
|
186
|
-
last_error: nil,
|
187
|
-
received_at: (ModelUtils.has_columns?(klass, :received_at) ? (record.received_at || now) : nil),
|
188
|
-
updated_at: (ModelUtils.has_columns?(klass, :updated_at) ? now : nil)
|
189
|
-
})
|
190
|
-
record.save!
|
191
|
-
|
192
|
-
# Hand off to your processor (expected to ack/nak on its own)
|
193
|
-
@processor.handle_message(m)
|
194
|
-
|
195
|
-
ModelUtils.assign_known_attrs(record, {
|
196
|
-
status: 'processed',
|
197
|
-
processed_at: (ModelUtils.has_columns?(klass, :processed_at) ? Time.now.utc : nil),
|
198
|
-
updated_at: (ModelUtils.has_columns?(klass, :updated_at) ? Time.now.utc : nil)
|
199
|
-
})
|
200
|
-
record.save!
|
201
|
-
|
202
|
-
true
|
203
|
-
rescue => e
|
204
|
-
# Try to persist the failure state; allow JetStream redelivery policy to handle retries
|
205
|
-
begin
|
206
|
-
if record
|
207
|
-
ModelUtils.assign_known_attrs(record, {
|
208
|
-
status: 'failed',
|
209
|
-
last_error: "#{e.class}: #{e.message}",
|
210
|
-
updated_at: (ModelUtils.has_columns?(klass, :updated_at) ? Time.now.utc : nil)
|
211
|
-
})
|
212
|
-
record.save!
|
213
|
-
end
|
214
|
-
rescue => e2
|
215
|
-
Logging.warn("Failed to persist inbox failure: #{e2.class}: #{e2.message}",
|
216
|
-
tag: 'JetstreamBridge::Consumer')
|
217
|
-
end
|
218
|
-
Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
|
219
|
-
tag: 'JetstreamBridge::Consumer')
|
220
|
-
# We do NOT ack here; let your MessageProcessor (or JS policy) handle redelivery/DLQ
|
221
|
-
false
|
222
|
-
end
|
223
|
-
# ----- /Inbox path -----
|
224
|
-
|
225
|
-
def recoverable_consumer_error?(error)
|
226
|
-
msg = error.message.to_s
|
227
|
-
msg =~ /consumer.*(not\s+found|deleted)/i ||
|
228
|
-
msg =~ /no\s+responders/i ||
|
229
|
-
msg =~ /stream.*not\s+found/i
|
230
|
-
end
|
231
|
-
end
|
232
|
-
end
|
data/lib/jetstream_bridge/dlq.rb
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative 'overlap_guard'
|
4
|
-
require_relative 'logging'
|
5
|
-
require_relative 'subject_matcher'
|
6
|
-
|
7
|
-
module JetstreamBridge
|
8
|
-
# Ensures the DLQ subject is added to the stream.
|
9
|
-
class DLQ
|
10
|
-
def self.ensure!(jts)
|
11
|
-
name = JetstreamBridge.config.stream_name
|
12
|
-
info = jts.stream_info(name)
|
13
|
-
subs = Array(info.config.subjects || [])
|
14
|
-
dlq = JetstreamBridge.config.dlq_subject
|
15
|
-
return if SubjectMatcher.covered?(subs, dlq)
|
16
|
-
|
17
|
-
desired = (subs + [dlq]).uniq
|
18
|
-
OverlapGuard.check!(jts, name, desired)
|
19
|
-
|
20
|
-
jts.update_stream(name: name, subjects: desired)
|
21
|
-
Logging.info("Added DLQ subject #{dlq} to stream #{name}", tag: 'JetstreamBridge::DLQ')
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
@@ -1,46 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
# If ActiveRecord is not available, a shim class is defined that raises
|
4
|
-
# a helpful error when used.
|
5
|
-
begin
|
6
|
-
require 'active_record'
|
7
|
-
rescue LoadError
|
8
|
-
# Ignore; we handle the lack of AR below.
|
9
|
-
end
|
10
|
-
|
11
|
-
module JetstreamBridge
|
12
|
-
# InboxEvent is the default ActiveRecord model used by the gem when
|
13
|
-
# `use_inbox` is enabled.
|
14
|
-
# It records processed event IDs for idempotency.
|
15
|
-
if defined?(ActiveRecord::Base)
|
16
|
-
class InboxEvent < ActiveRecord::Base
|
17
|
-
self.table_name = 'jetstream_inbox_events'
|
18
|
-
|
19
|
-
validates :event_id, presence: true, uniqueness: true
|
20
|
-
validates :subject, presence: true
|
21
|
-
end
|
22
|
-
else
|
23
|
-
# Shim that fails loudly if the app misconfigures the gem without AR.
|
24
|
-
class InboxEvent
|
25
|
-
class << self
|
26
|
-
def method_missing(method_name, *_args, &_block)
|
27
|
-
raise_missing_ar!('Inbox', method_name)
|
28
|
-
end
|
29
|
-
|
30
|
-
def respond_to_missing?(_method_name, _include_private = false)
|
31
|
-
false
|
32
|
-
end
|
33
|
-
|
34
|
-
private
|
35
|
-
|
36
|
-
def raise_missing_ar!(which, method_name)
|
37
|
-
raise(
|
38
|
-
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
39
|
-
'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
|
40
|
-
'`gem "activerecord"` to your Gemfile.'
|
41
|
-
)
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
end
|
46
|
-
end
|
@@ -1,60 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
# If ActiveRecord is not available, a shim class is defined that raises
|
4
|
-
# a helpful error when used.
|
5
|
-
begin
|
6
|
-
require 'active_record'
|
7
|
-
rescue LoadError
|
8
|
-
# Ignore; we handle the lack of AR below.
|
9
|
-
end
|
10
|
-
|
11
|
-
module JetstreamBridge
|
12
|
-
# OutboxEvent is the default ActiveRecord model used by the gem when
|
13
|
-
# `use_outbox` is enabled.
|
14
|
-
# It stores pending events that will be flushed
|
15
|
-
# to NATS JetStream by your worker/cron.
|
16
|
-
if defined?(ActiveRecord::Base)
|
17
|
-
class OutboxEvent < ActiveRecord::Base
|
18
|
-
self.table_name = 'jetstream_outbox_events'
|
19
|
-
|
20
|
-
# Prefer native JSON/JSONB column.
|
21
|
-
# If the column is text, `serialize` with JSON keeps backward compatibility.
|
22
|
-
if respond_to?(:attribute_types) &&
|
23
|
-
attribute_types.key?('payload') &&
|
24
|
-
attribute_types['payload'].type.in?(%i[json jsonb])
|
25
|
-
# No-op: AR already gives JSON casting.
|
26
|
-
else
|
27
|
-
serialize :payload, coder: JSON
|
28
|
-
end
|
29
|
-
|
30
|
-
# Minimal validations that are generally useful; keep them short.
|
31
|
-
validates :resource_type, presence: true
|
32
|
-
validates :resource_id, presence: true
|
33
|
-
validates :event_type, presence: true
|
34
|
-
validates :payload, presence: true
|
35
|
-
end
|
36
|
-
else
|
37
|
-
# Shim that fails loudly if the app misconfigures the gem without AR.
|
38
|
-
class OutboxEvent
|
39
|
-
class << self
|
40
|
-
def method_missing(method_name, *_args, &_block)
|
41
|
-
raise_missing_ar!('Outbox', method_name)
|
42
|
-
end
|
43
|
-
|
44
|
-
def respond_to_missing?(_method_name, _include_private = false)
|
45
|
-
false
|
46
|
-
end
|
47
|
-
|
48
|
-
private
|
49
|
-
|
50
|
-
def raise_missing_ar!(which, method_name)
|
51
|
-
raise(
|
52
|
-
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
53
|
-
'Enable `use_outbox` only in apps with ActiveRecord, or add ' \
|
54
|
-
'`gem "activerecord"` to your Gemfile.'
|
55
|
-
)
|
56
|
-
end
|
57
|
-
end
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
@@ -1,114 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative 'overlap_guard'
|
4
|
-
require_relative 'logging'
|
5
|
-
require_relative 'subject_matcher'
|
6
|
-
|
7
|
-
module JetstreamBridge
|
8
|
-
# Ensures a stream exists and adds only subjects that are not already covered.
|
9
|
-
class Stream
|
10
|
-
class << self
|
11
|
-
def ensure!(jts, name, subjects)
|
12
|
-
desired = normalize_subjects(subjects)
|
13
|
-
raise ArgumentError, 'subjects must not be empty' if desired.empty?
|
14
|
-
|
15
|
-
attempts = 0
|
16
|
-
|
17
|
-
begin
|
18
|
-
info = jts.stream_info(name)
|
19
|
-
existing = normalize_subjects(info.config.subjects || [])
|
20
|
-
|
21
|
-
# Skip anything already COVERED by existing patterns (not just exact match)
|
22
|
-
to_add = desired.reject { |d| SubjectMatcher.covered?(existing, d) }
|
23
|
-
if to_add.empty?
|
24
|
-
Logging.info("Stream #{name} exists; subjects already covered.", tag: 'JetstreamBridge::Stream')
|
25
|
-
return
|
26
|
-
end
|
27
|
-
|
28
|
-
# Filter out subjects owned by other streams to prevent overlap BadRequest
|
29
|
-
allowed, blocked = OverlapGuard.partition_allowed(jts, name, to_add)
|
30
|
-
|
31
|
-
if allowed.empty?
|
32
|
-
if blocked.any?
|
33
|
-
Logging.warn(
|
34
|
-
"Stream #{name}: all missing subjects are owned by other streams; leaving unchanged. " \
|
35
|
-
"blocked=#{blocked.inspect}",
|
36
|
-
tag: 'JetstreamBridge::Stream'
|
37
|
-
)
|
38
|
-
else
|
39
|
-
Logging.info("Stream #{name} exists; nothing to add.", tag: 'JetstreamBridge::Stream')
|
40
|
-
end
|
41
|
-
return
|
42
|
-
end
|
43
|
-
|
44
|
-
target = (existing + allowed).uniq
|
45
|
-
|
46
|
-
# Validate and update (race may still occur; handled in rescue)
|
47
|
-
OverlapGuard.check!(jts, name, target)
|
48
|
-
jts.update_stream(name: name, subjects: target)
|
49
|
-
|
50
|
-
Logging.info(
|
51
|
-
"Updated stream #{name}; added subjects=#{allowed.inspect}" \
|
52
|
-
"#{blocked.any? ? " (skipped overlapped=#{blocked.inspect})" : ''}",
|
53
|
-
tag: 'JetstreamBridge::Stream'
|
54
|
-
)
|
55
|
-
rescue NATS::JetStream::Error => e
|
56
|
-
if stream_not_found?(e)
|
57
|
-
# Creating fresh: still filter to avoid BadRequest
|
58
|
-
allowed, blocked = OverlapGuard.partition_allowed(jts, name, desired)
|
59
|
-
if allowed.empty?
|
60
|
-
Logging.warn(
|
61
|
-
"Not creating stream #{name}: all desired subjects are owned by other streams. " \
|
62
|
-
"blocked=#{blocked.inspect}",
|
63
|
-
tag: 'JetstreamBridge::Stream'
|
64
|
-
)
|
65
|
-
return
|
66
|
-
end
|
67
|
-
|
68
|
-
jts.add_stream(
|
69
|
-
name: name,
|
70
|
-
subjects: allowed,
|
71
|
-
retention: 'interest',
|
72
|
-
storage: 'file'
|
73
|
-
)
|
74
|
-
Logging.info(
|
75
|
-
"Created stream #{name} subjects=#{allowed.inspect}" \
|
76
|
-
"#{blocked.any? ? " (skipped overlapped=#{blocked.inspect})" : ''}",
|
77
|
-
tag: 'JetstreamBridge::Stream'
|
78
|
-
)
|
79
|
-
elsif overlap_error?(e) && (attempts += 1) <= 1
|
80
|
-
# Late race: re-fetch and try once more
|
81
|
-
Logging.warn("Overlap race while ensuring #{name}; retrying once...", tag: 'JetstreamBridge::Stream')
|
82
|
-
sleep(0.05)
|
83
|
-
retry
|
84
|
-
elsif overlap_error?(e)
|
85
|
-
# Give up gracefully (don’t raise) — someone else now owns a conflicting subject
|
86
|
-
Logging.warn(
|
87
|
-
"Overlap persists ensuring #{name}; leaving unchanged. err=#{e.message.inspect}",
|
88
|
-
tag: 'JetstreamBridge::Stream'
|
89
|
-
)
|
90
|
-
return
|
91
|
-
else
|
92
|
-
raise
|
93
|
-
end
|
94
|
-
end
|
95
|
-
end
|
96
|
-
|
97
|
-
private
|
98
|
-
|
99
|
-
def normalize_subjects(list)
|
100
|
-
Array(list).flatten.compact.map!(&:to_s).reject(&:empty?).uniq
|
101
|
-
end
|
102
|
-
|
103
|
-
def stream_not_found?(error)
|
104
|
-
msg = error.message.to_s
|
105
|
-
msg =~ /stream\s+not\s+found/i || msg =~ /\b404\b/
|
106
|
-
end
|
107
|
-
|
108
|
-
def overlap_error?(error)
|
109
|
-
msg = error.message.to_s
|
110
|
-
msg =~ /subjects?\s+overlap/i || msg =~ /\berr_code=10065\b/ || msg =~ /\bstatus_code=400\b/
|
111
|
-
end
|
112
|
-
end
|
113
|
-
end
|
114
|
-
end
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|