jetstream_bridge 1.4.0 β†’ 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 535eaf5e00d6c27b8f79c3d065128e917c8acddb36a696f7a097c535e28a20e2
4
- data.tar.gz: 0a6b2c99fea1b1f351b95664034ba879307182dbfaf2b67c399a9ff385ad96ab
3
+ metadata.gz: 52210d5d38366eed432001bc5ae522574a09fa73e5327e6509fc2a3983d7e2f5
4
+ data.tar.gz: fc9b02992b70a4584396177226cb1c21a83a87c96384d227825d6ffa9dcab958
5
5
  SHA512:
6
- metadata.gz: c0514b7de514f05785ca2208ce4613be7814702141c04bf5be3266b4f7b9e6d0406288d021715779efb23824fe45b5effa92ca4c9fc7f612586de7b97eb3cfe3
7
- data.tar.gz: 9f9cde6bfad36b565a1f7f7aa0ff7b889a4758ff91f86ee663296f85ef676c759de3b1268dd03f7de323bb9561f8530fe25d9c57b784bd2a55138afd833844bf
6
+ metadata.gz: bbb2379b20b45f15f5fded1bd4da19a0b5b2e12ba6df321e66b6a15bf00bb2be292dc8b4ec379d0223508c145689d8374531512d757a72cb397474d1794339ef
7
+ data.tar.gz: 119109cd2c60ab8885f8a47d33d7fb9e4e74a91ee39217bc1cfcbf13e6e4d5e64ebb7bfe8b7b1fcc4f4df39c72f12972b2e675d85e85a869c40d1ef92b106b8d
@@ -3,6 +3,7 @@
3
3
  <words>
4
4
  <w>activesupport</w>
5
5
  <w>backoffs</w>
6
+ <w>dedup</w>
6
7
  <w>esub</w>
7
8
  <w>msgs</w>
8
9
  <w>pipefail</w>
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jetstream_bridge (1.4.0)
4
+ jetstream_bridge (1.6.0)
5
5
  activerecord (>= 6.0)
6
6
  activesupport (>= 6.0)
7
7
  nats-pure (~> 2.4)
data/README.md CHANGED
@@ -1,18 +1,19 @@
1
1
  # Jetstream Bridge
2
2
 
3
3
  **Production-safe realtime data bridge** between systems using **NATS JetStream**.
4
- Includes durable consumers, backpressure, retries, **DLQ**, and optional **Inbox/Outbox** for end-to-end reliability.
4
+ Includes durable consumers, backpressure, retries, **DLQ**, optional **Inbox/Outbox**, and **overlap-safe stream provisioning**.
5
5
 
6
6
  ---
7
7
 
8
8
  ## ✨ Features
9
9
 
10
- - πŸ”Œ Simple **Publisher** and **Consumer** interfaces
11
- - πŸ›‘ **Outbox** (reliable send) & **Inbox** (idempotent receive)
12
- - 🧨 **DLQ** for poison messages
13
- - βš™οΈ Durable `pull_subscribe` with exponential backoff & `max_deliver`
14
- - 🎯 Configurable **source** and **destination** applications
15
- - πŸ“Š Built-in observability
10
+ * πŸ”Œ Simple **Publisher** and **Consumer** interfaces
11
+ * πŸ›‘ **Outbox** (reliable send) & **Inbox** (idempotent receive), opt-in
12
+ * 🧨 **DLQ** for poison messages
13
+ * βš™οΈ Durable `pull_subscribe` with backoff & `max_deliver`
14
+ * 🎯 Clear **source/destination** subject conventions
15
+ * 🧱 **Overlap-safe stream ensure** (prevents β€œsubjects overlap” BadRequest)
16
+ * πŸ“Š Built-in logging for visibility
16
17
 
17
18
  ---
18
19
 
@@ -34,31 +35,32 @@ bundle install
34
35
  ```ruby
35
36
  # config/initializers/jetstream_bridge.rb
36
37
  JetstreamBridge.configure do |config|
37
- # NATS Connection
38
+ # NATS connection
38
39
  config.nats_urls = ENV.fetch("NATS_URLS", "nats://localhost:4222")
39
- config.env = ENV.fetch("NATS_ENV", "development")
40
- config.app_name = ENV.fetch("APP_NAME", "app")
41
- config.destination_app = ENV["DESTINATION_APP"]
40
+ config.env = ENV.fetch("NATS_ENV", "development")
41
+ config.app_name = ENV.fetch("APP_NAME", "app")
42
+ config.destination_app = ENV["DESTINATION_APP"] # required
42
43
 
43
- # Consumer Tuning
44
+ # Consumer tuning
44
45
  config.max_deliver = 5
45
46
  config.ack_wait = "30s"
46
47
  config.backoff = %w[1s 5s 15s 30s 60s]
47
48
 
48
- # Reliability Features
49
+ # Reliability features (opt-in)
49
50
  config.use_outbox = true
50
51
  config.use_inbox = true
51
52
  config.use_dlq = true
52
53
 
53
- # Models (override if custom)
54
+ # Models (override if you use custom AR classes/table names)
54
55
  config.outbox_model = "JetstreamBridge::OutboxEvent"
55
56
  config.inbox_model = "JetstreamBridge::InboxEvent"
56
57
  end
57
58
  ```
58
59
 
59
- > **Note:**
60
- > - `stream_name` defaults to `{env}-stream-bridge`
61
- > - `dlq_subject` defaults to `data.sync.dlq`
60
+ > **Defaults:**
61
+ >
62
+ > * `stream_name` β†’ `#{env}-jetstream-bridge-stream`
63
+ > * `dlq_subject` β†’ `#{env}.data.sync.dlq`
62
64
 
63
65
  ---
64
66
 
@@ -70,50 +72,69 @@ end
70
72
  | **Subscribe** | `{env}.data.sync.{dest}.{app}` |
71
73
  | **DLQ** | `{env}.data.sync.dlq` |
72
74
 
73
- - `{app}`: Your `app_name`
74
- - `{dest}`: Your `destination_app`
75
- - `{env}`: Your `env``
75
+ * `{app}`: `app_name`
76
+ * `{dest}`: `destination_app`
77
+ * `{env}`: `env`
76
78
 
77
79
  ---
78
80
 
79
- ## πŸ—ƒ Database Setup (Inbox/Outbox)
81
+ ## 🧱 Stream Topology (auto-ensure & overlap-safe)
80
82
 
81
- Run the installer:
83
+ On first connection, Jetstream Bridge **ensures** a single stream exists for your `env` and that it covers:
82
84
 
83
- ```bash
84
- rails jetstream_bridge:install --all
85
- ```
85
+ * `source_subject` (`{env}.data.sync.{app}.{dest}`)
86
+ * `destination_subject` (`{env}.data.sync.{dest}.{app}`)
87
+ * `dlq_subject` (if enabled)
88
+
89
+ It’s **overlap-safe**:
90
+
91
+ * Skips adding subjects already covered by existing wildcards.
92
+ * Pre-filters subjects that belong to *other* streams to avoid `BadRequest: subjects overlap with an existing stream`.
93
+ * Retries once on concurrent races, then logs and continues safely.
94
+
95
+ ---
96
+
97
+ ## πŸ—ƒ Database Setup (Inbox / Outbox)
86
98
 
87
- This creates:
99
+ Inbox/Outbox are **optional**. The library detects columns at runtime and only sets those that exist, so you can start minimal and evolve later.
88
100
 
89
- 1. **Initializer** (`config/initializers/jetstream_bridge.rb`)
90
- 2. **Migrations** (if enabled):
101
+ ### Minimal schemas (recommended starting point)
91
102
 
92
103
  ```ruby
93
- # Outbox
94
- create_table :jetstream_outbox_events do |t|
95
- t.string :resource_type, null: false
96
- t.string :resource_id, null: false
97
- t.string :event_type, null: false
98
- t.jsonb :payload, null: false, default: {}
99
- t.datetime :published_at
100
- t.integer :attempts, default: 0
101
- t.text :last_error
104
+ # db/migrate/xxxx_create_outbox_events.rb
105
+ create_table :outbox_events do |t|
106
+ t.string :event_id, null: false, index: { unique: true }
107
+ t.string :subject, null: false
108
+ t.jsonb :payload, null: false, default: {} # stored envelope or payload
109
+ t.jsonb :headers, null: false, default: {} # e.g., { "Nats-Msg-Id": ... }
110
+ t.string :status, null: false, default: "pending" # pending|publishing|sent|failed
111
+ t.integer :attempts, null: false, default: 0
112
+ t.text :last_error
113
+ t.datetime :enqueued_at
114
+ t.datetime :sent_at
102
115
  t.timestamps
103
116
  end
104
- add_index :jetstream_outbox_events, [:resource_type, :resource_id]
105
117
 
106
- # Inbox
107
- create_table :jetstream_inbox_events do |t|
108
- t.string :event_id, null: false
109
- t.string :subject, null: false
118
+ # db/migrate/xxxx_create_inbox_events.rb
119
+ create_table :inbox_events do |t|
120
+ t.string :event_id, index: { unique: true } # dedupe key (preferred)
121
+ t.string :subject, null: false
122
+ t.jsonb :payload, null: false, default: {}
123
+ t.jsonb :headers, null: false, default: {}
124
+ t.string :stream
125
+ t.bigint :stream_seq
126
+ t.integer :deliveries
127
+ t.string :status, null: false, default: "received" # received|processing|processed|failed
128
+ t.text :last_error
129
+ t.datetime :received_at
110
130
  t.datetime :processed_at
111
- t.text :error
112
131
  t.timestamps
113
132
  end
114
- add_index :jetstream_inbox_events, :event_id, unique: true
115
133
  ```
116
134
 
135
+ > Already have tables named differently (e.g., `jetstream_outbox_events`)?
136
+ > Set `config.outbox_model` / `config.inbox_model` to your AR class names.
137
+
117
138
  ---
118
139
 
119
140
  ## πŸ“€ Publish Events
@@ -122,33 +143,20 @@ add_index :jetstream_inbox_events, :event_id, unique: true
122
143
  publisher = JetstreamBridge::Publisher.new
123
144
  publisher.publish(
124
145
  resource_type: "user",
125
- resource_id: "01H1234567890ABCDEF",
126
146
  event_type: "created",
127
- payload: { id: "01H...", name: "Ada" }
147
+ payload: { id: "01H...", name: "Ada" }, # resource_id inferred from payload[:id] / payload["id"]
148
+ # optional:
149
+ # event_id: "uuid-or-ulid",
150
+ # trace_id: "hex",
151
+ # occurred_at: Time.now.utc
128
152
  )
129
153
  ```
130
154
 
131
- > **Ephemeral Mode** (for short-lived scripts):
132
- > ```ruby
133
- > JetstreamBridge::Publisher.new(persistent: false).publish(...)
134
- > ```
155
+ * If **Outbox** is enabled, the publish call:
135
156
 
136
- ---
137
-
138
- ## πŸ”„ Outbox (If Enabled)
139
-
140
- Events are written to the Outbox table. Flush periodically:
141
-
142
- ```ruby
143
- # app/jobs/outbox_flush_job.rb
144
- class OutboxFlushJob < ApplicationJob
145
- def perform
146
- JetstreamBridge::Publisher.new.flush_outbox
147
- end
148
- end
149
- ```
150
-
151
- Schedule this job to run every minute.
157
+ * Upserts an outbox row by `event_id`
158
+ * Publishes with `Nats-Msg-Id` (idempotent)
159
+ * Marks status `sent` or records `failed` with `last_error`
152
160
 
153
161
  ---
154
162
 
@@ -156,58 +164,91 @@ Schedule this job to run every minute.
156
164
 
157
165
  ```ruby
158
166
  JetstreamBridge::Consumer.new(
159
- durable_name: "#{Rails.env}-peerapp-events",
167
+ durable_name: "#{Rails.env}-peerapp-consumers",
160
168
  batch_size: 25
161
169
  ) do |event, subject, deliveries|
162
170
  # Your idempotent domain logic here
163
- UserCreatedHandler.call(event.payload)
171
+ # `event` is the parsed envelope hash
172
+ UserCreatedHandler.call(event["payload"])
164
173
  end.run!
165
174
  ```
166
175
 
176
+ * If **Inbox** is enabled, the consumer:
177
+
178
+ * Dedupes by `event_id` (falls back to stream sequence if needed)
179
+ * Records processing state, errors, and timestamps
180
+ * Skips already-processed messages (acks immediately)
181
+
167
182
  ---
168
183
 
169
184
  ## πŸ“¬ Envelope Format
170
185
 
171
- Published events include:
172
-
173
186
  ```json
174
187
  {
175
188
  "event_id": "01H1234567890ABCDEF",
176
189
  "schema_version": 1,
190
+ "event_type": "created",
177
191
  "producer": "myapp",
178
192
  "resource_type": "user",
179
193
  "resource_id": "01H1234567890ABCDEF",
180
- "event_type": "created",
181
194
  "occurred_at": "2025-08-13T21:00:00Z",
182
195
  "trace_id": "abc123",
183
196
  "payload": { "id": "01H...", "name": "Ada" }
184
197
  }
185
198
  ```
186
199
 
200
+ * `resource_id` is inferred from `payload.id` when publishing.
201
+
202
+ ---
203
+
204
+ ## 🧨 Dead-Letter Queue (DLQ)
205
+
206
+ When enabled, the topology ensures the DLQ subject exists:
207
+ **`{env}.data.sync.dlq`**
208
+
209
+ You may run a separate process to subscribe and triage messages that exceed `max_deliver` or are NAK’ed to the DLQ.
210
+
187
211
  ---
188
212
 
189
213
  ## πŸ›  Operations Guide
190
214
 
191
215
  ### Monitoring
192
- - **Consumer Lag**: `nats consumer info <stream> <durable>`
193
- - **Outbox Growth**: Alert if `jetstream_outbox_events` grows unexpectedly
194
- - **DLQ Messages**: Monitor `data.sync.dlq` subscription
216
+
217
+ * **Consumer lag**: `nats consumer info <stream> <durable>`
218
+ * **DLQ volume**: subscribe/metrics on `{env}.data.sync.dlq`
219
+ * **Outbox backlog**: alert on `outbox_events` with `status != 'sent'` and growing count
195
220
 
196
221
  ### Scaling
197
- - Run consumers in **separate processes/containers**
198
- - Scale independently of web workers
222
+
223
+ * Run consumers in **separate processes/containers**
224
+ * Scale consumers independently from web
225
+ * Tune `batch_size`, `ack_wait`, `max_deliver`, and `backoff`
199
226
 
200
227
  ### When to Use
201
- - **Inbox**: When replays or duplicates are possible
202
- - **Outbox**: When "DB commit β‡’ event published" guarantee is required
228
+
229
+ * **Inbox**: you need idempotent processing and replay safety
230
+ * **Outbox**: you want β€œDB commit β‡’ event published (or recorded for retry)” guarantees
231
+
232
+ ---
233
+
234
+ ## 🧩 Troubleshooting
235
+
236
+ * **`subjects overlap with an existing stream`**
237
+ The library pre-filters overlapping subjects and retries once. If another team owns a broad wildcard (e.g., `env.data.sync.>`), coordinate subject boundaries.
238
+
239
+ * **Consumer exists with mismatched filter**
240
+ The library detects and recreates the durable with the desired filter subject.
241
+
242
+ * **Repeated redeliveries**
243
+ Increase `ack_wait`, review handler acks/NACKs, or move poison messages to DLQ.
203
244
 
204
245
  ---
205
246
 
206
247
  ## πŸš€ Getting Started
207
248
 
208
- 1. Install the gem
209
- 2. Configure the initializer
210
- 3. Run migrations: `rails db:migrate`
249
+ 1. Add the gem & run `bundle install`
250
+ 2. Create the initializer
251
+ 3. (Optional) Add Inbox/Outbox migrations and models
211
252
  4. Start publishing/consuming!
212
253
 
213
254
  ---
@@ -8,6 +8,7 @@ require_relative 'logging'
8
8
  require_relative 'consumer_config'
9
9
  require_relative 'message_processor'
10
10
  require_relative 'config'
11
+ require_relative 'model_utils'
11
12
 
12
13
  module JetstreamBridge
13
14
  # Subscribes to "{env}.data.sync.{dest}.{app}" and processes messages.
@@ -55,15 +56,42 @@ module JetstreamBridge
55
56
  JetstreamBridge.config.destination_subject
56
57
  end
57
58
 
59
+ def desired_consumer_cfg
60
+ ConsumerConfig.consumer_config(@durable, filter_subject)
61
+ end
62
+
58
63
  def ensure_consumer!
59
- @jts.consumer_info(stream_name, @durable)
60
- Logging.info("Consumer #{@durable} exists.", tag: 'JetstreamBridge::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
61
83
  rescue NATS::JetStream::Error
62
- @jts.add_consumer(stream_name, **ConsumerConfig.consumer_config(@durable, filter_subject))
84
+ @jts.add_consumer(stream_name, **desired_consumer_cfg)
63
85
  Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
64
86
  tag: 'JetstreamBridge::Consumer')
65
87
  end
66
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
+
67
95
  def subscribe!
68
96
  @psub = @jts.pull_subscribe(
69
97
  filter_subject,
@@ -78,12 +106,19 @@ module JetstreamBridge
78
106
  # Returns number of messages processed; 0 on timeout/idle or after recovery.
79
107
  def process_batch
80
108
  msgs = @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
81
- msgs.each { |m| @processor.handle_message(m) }
82
- msgs.size
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
83
119
  rescue NATS::Timeout, NATS::IO::Timeout
84
120
  0
85
121
  rescue NATS::JetStream::Error => e
86
- # Handle common recoverable states by re-ensuring consumer & subscription.
87
122
  if recoverable_consumer_error?(e)
88
123
  Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
89
124
  tag: 'JetstreamBridge::Consumer')
@@ -97,10 +132,101 @@ module JetstreamBridge
97
132
  end
98
133
  end
99
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
+
100
225
  def recoverable_consumer_error?(error)
101
226
  msg = error.message.to_s
102
227
  msg =~ /consumer.*(not\s+found|deleted)/i ||
103
- msg =~ /no\s+responders/i
228
+ msg =~ /no\s+responders/i ||
229
+ msg =~ /stream.*not\s+found/i
104
230
  end
105
231
  end
106
232
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ module ModelUtils
5
+ module_function
6
+
7
+ def constantize(name)
8
+ name.to_s.split('::').reduce(Object) { |m, c| m.const_get(c) }
9
+ end
10
+
11
+ def ar_class?(klass)
12
+ defined?(ActiveRecord::Base) && klass <= ActiveRecord::Base
13
+ end
14
+
15
+ def has_columns?(klass, *cols)
16
+ return false unless ar_class?(klass)
17
+ cols.flatten.all? { |c| klass.column_names.include?(c.to_s) }
18
+ end
19
+
20
+ def assign_known_attrs(record, attrs)
21
+ attrs.each do |k, v|
22
+ setter = :"#{k}="
23
+ record.public_send(setter, v) if record.respond_to?(setter)
24
+ end
25
+ end
26
+
27
+ # find_or_initialize_by on the first keyset whose columns exist; else new
28
+ def find_or_init_by_best(klass, *keysets)
29
+ keysets.each do |keys|
30
+ next if keys.nil? || keys.empty?
31
+ if has_columns?(klass, keys.keys)
32
+ return klass.find_or_initialize_by(keys)
33
+ end
34
+ end
35
+ klass.new
36
+ end
37
+
38
+ def json_dump(obj)
39
+ obj.is_a?(String) ? obj : JSON.generate(obj)
40
+ rescue
41
+ obj.to_s
42
+ end
43
+
44
+ def json_load(str)
45
+ return str if str.is_a?(Hash)
46
+ JSON.parse(str.to_s)
47
+ rescue
48
+ {}
49
+ end
50
+ end
51
+ end
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative 'subject_matcher'
5
+ require_relative 'logging'
4
6
 
5
7
  module JetstreamBridge
6
8
  # Checks for overlapping subjects.
7
9
  class OverlapGuard
8
10
  class << self
11
+ # Raise if any desired subjects conflict with other streams.
9
12
  def check!(jts, target_name, new_subjects)
10
13
  conflicts = overlaps(jts, target_name, new_subjects)
11
14
  return if conflicts.empty?
12
15
  raise conflict_message(target_name, conflicts)
13
16
  end
14
17
 
18
+ # Return a list of conflicts against other streams, per subject.
19
+ # [{ name:'OTHER' pairs: [['a.b.*', 'a.b.c'], ...] }, ...]
15
20
  def overlaps(jts, target_name, new_subjects)
16
21
  desired = Array(new_subjects).map!(&:to_s).uniq
17
22
  streams = list_streams_with_subjects(jts)
@@ -26,6 +31,19 @@ module JetstreamBridge
26
31
  end.compact
27
32
  end
28
33
 
34
+ # Returns [allowed, blocked] given desired subjects.
35
+ def partition_allowed(jts, target_name, desired_subjects)
36
+ desired = Array(desired_subjects).map!(&:to_s).uniq
37
+ conflicts = overlaps(jts, target_name, desired)
38
+ blocked = conflicts.flat_map { |c| c[:pairs].map(&:first) }.uniq
39
+ allowed = desired - blocked
40
+ [allowed, blocked]
41
+ end
42
+
43
+ def allowed_subjects(jts, target_name, desired_subjects)
44
+ partition_allowed(jts, target_name, desired_subjects).first
45
+ end
46
+
29
47
  def list_streams_with_subjects(jts)
30
48
  list_stream_names(jts).map do |name|
31
49
  info = jts.stream_info(name)
@@ -5,6 +5,7 @@ require 'securerandom'
5
5
  require_relative 'connection'
6
6
  require_relative 'logging'
7
7
  require_relative 'config'
8
+ require_relative 'model_utils'
8
9
 
9
10
  module JetstreamBridge
10
11
  # Publishes to "{env}.data.sync.{app}.{dest}".
@@ -27,7 +28,12 @@ module JetstreamBridge
27
28
  ensure_destination!
28
29
  envelope = build_envelope(resource_type, event_type, payload, options)
29
30
  subject = JetstreamBridge.config.source_subject
30
- with_retries { do_publish(subject, envelope) }
31
+
32
+ if JetstreamBridge.config.use_outbox
33
+ publish_via_outbox(subject, envelope)
34
+ else
35
+ with_retries { do_publish(subject, envelope) }
36
+ end
31
37
  rescue StandardError => e
32
38
  log_error(false, e)
33
39
  end
@@ -47,7 +53,86 @@ module JetstreamBridge
47
53
  true
48
54
  end
49
55
 
50
- # Retry only on transient NATS errors
56
+ # ---- Outbox path ----
57
+ def publish_via_outbox(subject, envelope)
58
+ klass = ModelUtils.constantize(JetstreamBridge.config.outbox_model)
59
+
60
+ unless ModelUtils.ar_class?(klass)
61
+ Logging.warn("Outbox model #{klass} is not an ActiveRecord model; publishing directly.",
62
+ tag: 'JetstreamBridge::Publisher')
63
+ return with_retries { do_publish(subject, envelope) }
64
+ end
65
+
66
+ now = Time.now.utc
67
+ event_id = envelope['event_id'].to_s
68
+
69
+ record = ModelUtils.find_or_init_by_best(
70
+ klass,
71
+ { event_id: event_id },
72
+ # Fallback key if app uses a different unique column:
73
+ { dedup_key: event_id }
74
+ )
75
+
76
+ # If already sent, do nothing
77
+ if record.respond_to?(:sent_at) && record.sent_at
78
+ Logging.info("Outbox already sent event_id=#{event_id}; skipping publish.",
79
+ tag: 'JetstreamBridge::Publisher')
80
+ return true
81
+ end
82
+
83
+ # populate / update
84
+ ModelUtils.assign_known_attrs(record, {
85
+ event_id: event_id,
86
+ subject: subject,
87
+ payload: ModelUtils.json_dump(envelope),
88
+ headers: ModelUtils.json_dump({ 'Nats-Msg-Id' => event_id }),
89
+ status: 'publishing',
90
+ attempts: (record.respond_to?(:attempts) ? (record.attempts || 0) + 1 : nil),
91
+ last_error: nil,
92
+ enqueued_at: (record.respond_to?(:enqueued_at) ? (record.enqueued_at || now) : nil),
93
+ updated_at: (record.respond_to?(:updated_at) ? now : nil)
94
+ })
95
+ record.save!
96
+
97
+ ok = with_retries { do_publish(subject, envelope) }
98
+
99
+ if ok
100
+ ModelUtils.assign_known_attrs(record, {
101
+ status: 'sent',
102
+ sent_at: (record.respond_to?(:sent_at) ? now : nil),
103
+ updated_at: (record.respond_to?(:updated_at) ? now : nil)
104
+ })
105
+ record.save!
106
+ else
107
+ ModelUtils.assign_known_attrs(record, {
108
+ status: 'failed',
109
+ last_error: 'Publish returned false',
110
+ updated_at: (record.respond_to?(:updated_at) ? now : nil)
111
+ })
112
+ record.save!
113
+ end
114
+
115
+ ok
116
+ rescue => e
117
+ # Persist the failure on the outbox row as best as we can
118
+ begin
119
+ if record
120
+ ModelUtils.assign_known_attrs(record, {
121
+ status: 'failed',
122
+ last_error: "#{e.class}: #{e.message}",
123
+ updated_at: (record.respond_to?(:updated_at) ? Time.now.utc : nil)
124
+ })
125
+ record.save!
126
+ end
127
+ rescue => e2
128
+ Logging.warn("Failed to persist outbox failure: #{e2.class}: #{e2.message}",
129
+ tag: 'JetstreamBridge::Publisher')
130
+ end
131
+ log_error(false, e)
132
+ end
133
+ # ---- /Outbox path ----
134
+
135
+ # Retry only on transient NATS IO errors
51
136
  def with_retries(retries = DEFAULT_RETRIES)
52
137
  attempts = 0
53
138
  begin
@@ -12,34 +12,82 @@ module JetstreamBridge
12
12
  desired = normalize_subjects(subjects)
13
13
  raise ArgumentError, 'subjects must not be empty' if desired.empty?
14
14
 
15
+ attempts = 0
16
+
15
17
  begin
16
18
  info = jts.stream_info(name)
17
19
  existing = normalize_subjects(info.config.subjects || [])
18
20
 
19
21
  # Skip anything already COVERED by existing patterns (not just exact match)
20
- missing = desired.reject { |d| SubjectMatcher.covered?(existing, d) }
21
- if missing.empty?
22
+ to_add = desired.reject { |d| SubjectMatcher.covered?(existing, d) }
23
+ if to_add.empty?
22
24
  Logging.info("Stream #{name} exists; subjects already covered.", tag: 'JetstreamBridge::Stream')
23
25
  return
24
26
  end
25
27
 
26
- # Validate full target set against other streams
27
- target = (existing + missing).uniq
28
- OverlapGuard.check!(jts, name, target)
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
29
43
 
30
- # Try to update; handle late overlaps/races
44
+ target = (existing + allowed).uniq
45
+
46
+ # Validate and update (race may still occur; handled in rescue)
47
+ OverlapGuard.check!(jts, name, target)
31
48
  jts.update_stream(name: name, subjects: target)
32
- Logging.info("Updated stream #{name}; added subjects=#{missing.inspect}", tag: 'JetstreamBridge::Stream')
49
+
50
+ Logging.info(
51
+ "Updated stream #{name}; added subjects=#{allowed.inspect}" \
52
+ "#{blocked.any? ? " (skipped overlapped=#{blocked.inspect})" : ''}",
53
+ tag: 'JetstreamBridge::Stream'
54
+ )
33
55
  rescue NATS::JetStream::Error => e
34
56
  if stream_not_found?(e)
35
- # Race: created elsewhere or genuinely missing β€” create fresh
36
- OverlapGuard.check!(jts, name, desired)
37
- jts.add_stream(name: name, subjects: desired, retention: 'interest', storage: 'file')
38
- Logging.info("Created stream #{name} subjects=#{desired.inspect}", tag: 'JetstreamBridge::Stream')
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
39
84
  elsif overlap_error?(e)
40
- # Late overlap due to concurrent change β€” recompute and raise with details
41
- conflicts = OverlapGuard.overlaps(jts, name, desired)
42
- raise OverlapGuard.conflict_message(name, conflicts)
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
43
91
  else
44
92
  raise
45
93
  end
@@ -11,7 +11,7 @@ module JetstreamBridge
11
11
 
12
12
  # Proper NATS semantics:
13
13
  # - '*' matches exactly one token
14
- # - '>' matches the rest (zero or more tokens) but ONLY after the fixed prefix matches
14
+ # - '>' matches the rest (zero or more tokens)
15
15
  def match?(pattern, subject)
16
16
  p = pattern.split('.')
17
17
  s = subject.split('.')
@@ -30,9 +30,10 @@ module JetstreamBridge
30
30
  i += 1
31
31
  end
32
32
 
33
- # If the pattern still has tokens, it only matches if the remainder is a '>' (or contains one)
33
+ # Exact match
34
34
  return true if i == p.length && i == s.length
35
35
 
36
+ # If pattern has remaining '>' it can absorb remainder
36
37
  p[i] == '>' || p[i..-1]&.include?('>')
37
38
  end
38
39
 
@@ -47,8 +48,8 @@ module JetstreamBridge
47
48
  while ai < a_parts.length && bi < b_parts.length
48
49
  at = a_parts[ai]
49
50
  bt = b_parts[bi]
50
- return true if at == '>' || bt == '>' # either can absorb the rest
51
- return false unless at == bt || at == '*' || bt == '*' # fixed tokens differ
51
+ return true if at == '>' || bt == '>'
52
+ return false unless at == bt || at == '*' || bt == '*'
52
53
  ai += 1
53
54
  bi += 1
54
55
  end
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '1.4.0'
7
+ VERSION = '1.6.0'
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jetstream_bridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Attara
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-18 00:00:00.000000000 Z
11
+ date: 2025-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -197,6 +197,7 @@ files:
197
197
  - lib/jetstream_bridge/inbox_event.rb
198
198
  - lib/jetstream_bridge/logging.rb
199
199
  - lib/jetstream_bridge/message_processor.rb
200
+ - lib/jetstream_bridge/model_utils.rb
200
201
  - lib/jetstream_bridge/outbox_event.rb
201
202
  - lib/jetstream_bridge/overlap_guard.rb
202
203
  - lib/jetstream_bridge/publisher.rb