jetstream_bridge 1.5.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.idea/dictionaries/project.xml +2 -0
  3. data/.idea/jetstream_bridge.iml +6 -1
  4. data/.rubocop.yml +102 -0
  5. data/Gemfile.lock +1 -5
  6. data/README.md +163 -78
  7. data/jetstream_bridge.gemspec +9 -10
  8. data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +16 -0
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +24 -0
  10. data/lib/generators/jetstream_bridge/install/install_generator.rb +19 -0
  11. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +44 -0
  12. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +24 -0
  13. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +21 -0
  14. data/lib/jetstream_bridge/consumer/consumer.rb +103 -0
  15. data/lib/jetstream_bridge/{consumer_config.rb → consumer/consumer_config.rb} +3 -3
  16. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +50 -0
  17. data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +51 -0
  18. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +102 -0
  19. data/lib/jetstream_bridge/{message_processor.rb → consumer/message_processor.rb} +1 -1
  20. data/lib/jetstream_bridge/consumer/subscription_manager.rb +91 -0
  21. data/lib/jetstream_bridge/{connection.rb → core/connection.rb} +1 -1
  22. data/lib/jetstream_bridge/core/model_utils.rb +51 -0
  23. data/lib/jetstream_bridge/models/inbox_event.rb +98 -0
  24. data/lib/jetstream_bridge/models/outbox_event.rb +114 -0
  25. data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
  26. data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +41 -4
  27. data/lib/jetstream_bridge/railtie.rb +12 -0
  28. data/lib/jetstream_bridge/tasks/install.rake +10 -0
  29. data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
  30. data/lib/jetstream_bridge/topology/stream.rb +129 -0
  31. data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
  32. data/lib/jetstream_bridge/version.rb +1 -1
  33. data/lib/jetstream_bridge.rb +35 -23
  34. metadata +49 -49
  35. data/lib/jetstream_bridge/consumer.rb +0 -136
  36. data/lib/jetstream_bridge/dlq.rb +0 -24
  37. data/lib/jetstream_bridge/inbox_event.rb +0 -46
  38. data/lib/jetstream_bridge/outbox_event.rb +0 -60
  39. data/lib/jetstream_bridge/stream.rb +0 -114
  40. /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
  41. /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
  42. /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
  43. /data/lib/jetstream_bridge/{subject_matcher.rb → topology/subject_matcher.rb} +0 -0
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: 1.5.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Attara
@@ -67,75 +67,61 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '6.0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: rake
70
+ name: bundler-audit
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
74
74
  - !ruby/object:Gem::Version
75
- version: '13.0'
75
+ version: 0.9.1
76
76
  type: :development
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: '13.0'
82
+ version: 0.9.1
83
83
  - !ruby/object:Gem::Dependency
84
- name: rspec
84
+ name: rake
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - ">="
88
88
  - !ruby/object:Gem::Version
89
- version: '3.12'
89
+ version: '13.0'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - ">="
95
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'
96
+ version: '13.0'
111
97
  - !ruby/object:Gem::Dependency
112
- name: rubocop-performance
98
+ name: rspec
113
99
  requirement: !ruby/object:Gem::Requirement
114
100
  requirements:
115
- - - "~>"
101
+ - - ">="
116
102
  - !ruby/object:Gem::Version
117
- version: '1.21'
103
+ version: '3.12'
118
104
  type: :development
119
105
  prerelease: false
120
106
  version_requirements: !ruby/object:Gem::Requirement
121
107
  requirements:
122
- - - "~>"
108
+ - - ">="
123
109
  - !ruby/object:Gem::Version
124
- version: '1.21'
110
+ version: '3.12'
125
111
  - !ruby/object:Gem::Dependency
126
- name: rubocop-rake
112
+ name: rubocop
127
113
  requirement: !ruby/object:Gem::Requirement
128
114
  requirements:
129
115
  - - "~>"
130
116
  - !ruby/object:Gem::Version
131
- version: '0.6'
117
+ version: '1.66'
132
118
  type: :development
133
119
  prerelease: false
134
120
  version_requirements: !ruby/object:Gem::Requirement
135
121
  requirements:
136
122
  - - "~>"
137
123
  - !ruby/object:Gem::Version
138
- version: '0.6'
124
+ version: '1.66'
139
125
  - !ruby/object:Gem::Dependency
140
126
  name: rubocop-packaging
141
127
  requirement: !ruby/object:Gem::Requirement
@@ -151,19 +137,19 @@ dependencies:
151
137
  - !ruby/object:Gem::Version
152
138
  version: '0.5'
153
139
  - !ruby/object:Gem::Dependency
154
- name: bundler-audit
140
+ name: rubocop-performance
155
141
  requirement: !ruby/object:Gem::Requirement
156
142
  requirements:
157
- - - ">="
143
+ - - "~>"
158
144
  - !ruby/object:Gem::Version
159
- version: 0.9.1
145
+ version: '1.21'
160
146
  type: :development
161
147
  prerelease: false
162
148
  version_requirements: !ruby/object:Gem::Requirement
163
149
  requirements:
164
- - - ">="
150
+ - - "~>"
165
151
  - !ruby/object:Gem::Version
166
- version: 0.9.1
152
+ version: '1.21'
167
153
  description: |-
168
154
  Publisher/Consumer utilities for NATS JetStream with environment-scoped subjects,
169
155
  overlap guards, DLQ routing, retries/backoff, and optional Inbox/Outbox patterns.
@@ -182,27 +168,41 @@ files:
182
168
  - ".idea/misc.xml"
183
169
  - ".idea/modules.xml"
184
170
  - ".idea/vcs.xml"
171
+ - ".rubocop.yml"
185
172
  - Gemfile
186
173
  - Gemfile.lock
187
174
  - LICENSE
188
175
  - README.md
189
176
  - jetstream_bridge.gemspec
177
+ - lib/generators/jetstream_bridge/initializer/initializer_generator.rb
178
+ - lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb
179
+ - lib/generators/jetstream_bridge/install/install_generator.rb
180
+ - lib/generators/jetstream_bridge/migrations/migrations_generator.rb
181
+ - lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb
182
+ - lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb
190
183
  - lib/jetstream_bridge.rb
191
- - lib/jetstream_bridge/config.rb
192
- - lib/jetstream_bridge/connection.rb
193
- - lib/jetstream_bridge/consumer.rb
194
- - lib/jetstream_bridge/consumer_config.rb
195
- - lib/jetstream_bridge/dlq.rb
196
- - lib/jetstream_bridge/duration.rb
197
- - lib/jetstream_bridge/inbox_event.rb
198
- - lib/jetstream_bridge/logging.rb
199
- - lib/jetstream_bridge/message_processor.rb
200
- - lib/jetstream_bridge/outbox_event.rb
201
- - lib/jetstream_bridge/overlap_guard.rb
202
- - lib/jetstream_bridge/publisher.rb
203
- - lib/jetstream_bridge/stream.rb
204
- - lib/jetstream_bridge/subject_matcher.rb
205
- - lib/jetstream_bridge/topology.rb
184
+ - lib/jetstream_bridge/consumer/consumer.rb
185
+ - lib/jetstream_bridge/consumer/consumer_config.rb
186
+ - lib/jetstream_bridge/consumer/inbox/inbox_message.rb
187
+ - lib/jetstream_bridge/consumer/inbox/inbox_processor.rb
188
+ - lib/jetstream_bridge/consumer/inbox/inbox_repository.rb
189
+ - lib/jetstream_bridge/consumer/message_processor.rb
190
+ - lib/jetstream_bridge/consumer/subscription_manager.rb
191
+ - lib/jetstream_bridge/core/config.rb
192
+ - lib/jetstream_bridge/core/connection.rb
193
+ - lib/jetstream_bridge/core/duration.rb
194
+ - lib/jetstream_bridge/core/logging.rb
195
+ - lib/jetstream_bridge/core/model_utils.rb
196
+ - lib/jetstream_bridge/models/inbox_event.rb
197
+ - lib/jetstream_bridge/models/outbox_event.rb
198
+ - lib/jetstream_bridge/publisher/outbox_repository.rb
199
+ - lib/jetstream_bridge/publisher/publisher.rb
200
+ - lib/jetstream_bridge/railtie.rb
201
+ - lib/jetstream_bridge/tasks/install.rake
202
+ - lib/jetstream_bridge/topology/overlap_guard.rb
203
+ - lib/jetstream_bridge/topology/stream.rb
204
+ - lib/jetstream_bridge/topology/subject_matcher.rb
205
+ - lib/jetstream_bridge/topology/topology.rb
206
206
  - lib/jetstream_bridge/version.rb
207
207
  homepage: https://github.com/attaradev/jetstream_bridge
208
208
  licenses:
@@ -1,136 +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
-
12
- module JetstreamBridge
13
- # Subscribes to "{env}.data.sync.{dest}.{app}" and processes messages.
14
- class Consumer
15
- DEFAULT_BATCH_SIZE = 25
16
- FETCH_TIMEOUT_SECS = 5
17
- IDLE_SLEEP_SECS = 0.05
18
-
19
- # @param durable_name [String] Consumer name
20
- # @param batch_size [Integer] Max messages per fetch
21
- # @yield [event, subject, deliveries] Message handler
22
- def initialize(durable_name:, batch_size: DEFAULT_BATCH_SIZE, &block)
23
- @handler = block
24
- @batch_size = batch_size
25
- @durable = durable_name
26
- @jts = Connection.connect!
27
-
28
- ensure_destination!
29
- ensure_consumer!
30
- subscribe!
31
- @processor = MessageProcessor.new(@jts, @handler)
32
- end
33
-
34
- # Starts the consumer loop.
35
- def run!
36
- Logging.info("Consumer #{@durable} started…", tag: 'JetstreamBridge::Consumer')
37
- loop do
38
- processed = process_batch
39
- sleep(IDLE_SLEEP_SECS) if processed.zero?
40
- end
41
- end
42
-
43
- private
44
-
45
- def ensure_destination!
46
- return unless JetstreamBridge.config.destination_app.to_s.empty?
47
- raise ArgumentError, 'destination_app must be configured'
48
- end
49
-
50
- def stream_name
51
- JetstreamBridge.config.stream_name
52
- end
53
-
54
- def filter_subject
55
- JetstreamBridge.config.destination_subject
56
- end
57
-
58
- def desired_consumer_cfg
59
- ConsumerConfig.consumer_config(@durable, filter_subject)
60
- end
61
-
62
- def ensure_consumer!
63
- info = @jts.consumer_info(stream_name, @durable)
64
- if consumer_mismatch?(info, desired_consumer_cfg)
65
- Logging.warn(
66
- "Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
67
- tag: 'JetstreamBridge::Consumer'
68
- )
69
- # Be tolerant if delete fails due to races
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
- # Not found -> create fresh
85
- @jts.add_consumer(stream_name, **desired_consumer_cfg)
86
- Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
87
- tag: 'JetstreamBridge::Consumer')
88
- end
89
-
90
- def consumer_mismatch?(info, desired_cfg)
91
- cfg = info.config
92
- (cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
93
- desired_cfg[:filter_subject].to_s
94
- end
95
-
96
- def subscribe!
97
- @psub = @jts.pull_subscribe(
98
- filter_subject,
99
- @durable,
100
- stream: stream_name,
101
- config: ConsumerConfig.subscribe_config
102
- )
103
- Logging.info("Subscribed to #{filter_subject} (durable=#{@durable})",
104
- tag: 'JetstreamBridge::Consumer')
105
- end
106
-
107
- # Returns number of messages processed; 0 on timeout/idle or after recovery.
108
- def process_batch
109
- msgs = @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
110
- msgs.each { |m| @processor.handle_message(m) }
111
- msgs.size
112
- rescue NATS::Timeout, NATS::IO::Timeout
113
- 0
114
- rescue NATS::JetStream::Error => e
115
- # Handle common recoverable states by re-ensuring consumer & subscription.
116
- if recoverable_consumer_error?(e)
117
- Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
118
- tag: 'JetstreamBridge::Consumer')
119
- ensure_consumer!
120
- subscribe!
121
- 0
122
- else
123
- Logging.error("Fetch failed: #{e.class} #{e.message}",
124
- tag: 'JetstreamBridge::Consumer')
125
- 0
126
- end
127
- end
128
-
129
- def recoverable_consumer_error?(error)
130
- msg = error.message.to_s
131
- msg =~ /consumer.*(not\s+found|deleted)/i ||
132
- msg =~ /no\s+responders/i ||
133
- msg =~ /stream.*not\s+found/i
134
- end
135
- end
136
- end
@@ -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