jetstream_bridge 1.6.0 → 1.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.idea/dictionaries/project.xml +1 -0
  3. data/.idea/jetstream_bridge.iml +6 -1
  4. data/.rubocop.yml +102 -0
  5. data/Gemfile.lock +1 -7
  6. data/README.md +76 -32
  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/models/inbox_event.rb +101 -0
  23. data/lib/jetstream_bridge/models/outbox_event.rb +100 -0
  24. data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
  25. data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +10 -58
  26. data/lib/jetstream_bridge/railtie.rb +37 -0
  27. data/lib/jetstream_bridge/tasks/install.rake +10 -0
  28. data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
  29. data/lib/jetstream_bridge/topology/stream.rb +129 -0
  30. data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
  31. data/lib/jetstream_bridge/version.rb +1 -1
  32. data/lib/jetstream_bridge.rb +35 -23
  33. metadata +49 -50
  34. data/lib/jetstream_bridge/consumer.rb +0 -232
  35. data/lib/jetstream_bridge/dlq.rb +0 -24
  36. data/lib/jetstream_bridge/inbox_event.rb +0 -46
  37. data/lib/jetstream_bridge/outbox_event.rb +0 -60
  38. data/lib/jetstream_bridge/stream.rb +0 -114
  39. /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
  40. /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
  41. /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
  42. /data/lib/jetstream_bridge/{model_utils.rb → core/model_utils.rb} +0 -0
  43. /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
@@ -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