jetstream_bridge 4.0.4 → 4.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/README.md +22 -1402
  4. data/docs/GETTING_STARTED.md +92 -0
  5. data/docs/PRODUCTION.md +503 -0
  6. data/docs/TESTING.md +414 -0
  7. data/lib/jetstream_bridge/consumer/consumer.rb +101 -5
  8. data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +17 -3
  9. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +19 -7
  10. data/lib/jetstream_bridge/consumer/message_processor.rb +88 -52
  11. data/lib/jetstream_bridge/consumer/subscription_manager.rb +24 -15
  12. data/lib/jetstream_bridge/core/bridge_helpers.rb +85 -0
  13. data/lib/jetstream_bridge/core/config.rb +27 -4
  14. data/lib/jetstream_bridge/core/connection.rb +162 -13
  15. data/lib/jetstream_bridge/core.rb +8 -0
  16. data/lib/jetstream_bridge/models/inbox_event.rb +13 -7
  17. data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
  18. data/lib/jetstream_bridge/publisher/publisher.rb +10 -5
  19. data/lib/jetstream_bridge/rails/integration.rb +153 -0
  20. data/lib/jetstream_bridge/rails/railtie.rb +53 -0
  21. data/lib/jetstream_bridge/rails.rb +5 -0
  22. data/lib/jetstream_bridge/tasks/install.rake +1 -1
  23. data/lib/jetstream_bridge/test_helpers/fixtures.rb +41 -0
  24. data/lib/jetstream_bridge/test_helpers/integration_helpers.rb +77 -0
  25. data/lib/jetstream_bridge/test_helpers/matchers.rb +98 -0
  26. data/lib/jetstream_bridge/test_helpers/mock_nats.rb +524 -0
  27. data/lib/jetstream_bridge/test_helpers.rb +85 -121
  28. data/lib/jetstream_bridge/topology/overlap_guard.rb +46 -0
  29. data/lib/jetstream_bridge/topology/stream.rb +7 -4
  30. data/lib/jetstream_bridge/version.rb +1 -1
  31. data/lib/jetstream_bridge.rb +138 -63
  32. metadata +32 -12
  33. data/lib/jetstream_bridge/railtie.rb +0 -49
data/docs/TESTING.md ADDED
@@ -0,0 +1,414 @@
1
+ # Testing with Mock NATS
2
+
3
+ JetstreamBridge provides a comprehensive in-memory mock for NATS JetStream that allows you to test your application without requiring a real NATS server.
4
+
5
+ ## Overview
6
+
7
+ The mock NATS implementation simulates:
8
+
9
+ - NATS connection lifecycle (connect, disconnect, reconnect)
10
+ - JetStream publish/subscribe operations
11
+ - Message acknowledgment (ACK, NAK, TERM)
12
+ - Consumer durable state and message redelivery
13
+ - Duplicate message detection
14
+ - Stream and consumer management
15
+ - Error scenarios
16
+
17
+ ## Basic Usage
18
+
19
+ ### With Test Helpers (Recommended)
20
+
21
+ The easiest way to use the mock is through the test helpers. The mock automatically integrates with the Connection class, so no additional mocking is needed:
22
+
23
+ ```ruby
24
+ require 'jetstream_bridge/test_helpers'
25
+
26
+ RSpec.describe MyService do
27
+ include JetstreamBridge::TestHelpers
28
+ include JetstreamBridge::TestHelpers::Matchers
29
+
30
+ before do
31
+ # Reset singleton to ensure clean state
32
+ JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
33
+ JetstreamBridge.reset!
34
+
35
+ # Enable test mode - automatically sets up mock NATS
36
+ JetstreamBridge::TestHelpers.enable_test_mode!
37
+
38
+ JetstreamBridge.configure do |config|
39
+ config.env = 'test'
40
+ config.app_name = 'my_app'
41
+ config.destination_app = 'worker'
42
+ end
43
+
44
+ # Setup mock stream and stub topology
45
+ mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
46
+ mock_jts.add_stream(
47
+ name: 'test-jetstream-bridge-stream',
48
+ subjects: ['test.>']
49
+ )
50
+ allow(JetstreamBridge::Topology).to receive(:ensure!)
51
+ end
52
+
53
+ after do
54
+ JetstreamBridge::TestHelpers.reset_test_mode!
55
+ JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
56
+ end
57
+
58
+ it 'publishes events through the full stack' do
59
+ result = JetstreamBridge.publish(
60
+ event_type: 'user.created',
61
+ resource_type: 'user',
62
+ payload: { id: 1, name: 'Ada' }
63
+ )
64
+
65
+ expect(result.success?).to be true
66
+ expect(result.duplicate?).to be false
67
+
68
+ # Can also use matchers (requires test mode)
69
+ expect(JetstreamBridge).to have_published(
70
+ event_type: 'user.created',
71
+ payload: hash_including(name: 'Ada')
72
+ )
73
+ end
74
+ end
75
+ ```
76
+
77
+ ### Manual Mock Setup
78
+
79
+ For more control, you can manually set up the mock:
80
+
81
+ ```ruby
82
+ require 'jetstream_bridge/test_helpers/mock_nats'
83
+
84
+ RSpec.describe 'Publishing' do
85
+ let(:mock_connection) { JetstreamBridge::TestHelpers::MockNats.create_mock_connection }
86
+ let(:jetstream) { mock_connection.jetstream }
87
+
88
+ before do
89
+ mock_connection.connect
90
+ allow(NATS::IO::Client).to receive(:new).and_return(mock_connection)
91
+ end
92
+
93
+ it 'publishes a message' do
94
+ ack = jetstream.publish(
95
+ 'test.subject',
96
+ { user_id: 1 }.to_json,
97
+ header: { 'nats-msg-id' => 'unique-id' }
98
+ )
99
+
100
+ expect(ack.duplicate?).to be false
101
+ expect(ack.sequence).to eq(1)
102
+ end
103
+ end
104
+ ```
105
+
106
+ ## Features
107
+
108
+ ### Publishing Events
109
+
110
+ The mock tracks all published messages and supports duplicate detection:
111
+
112
+ ```ruby
113
+ mock_jts = mock_connection.jetstream
114
+
115
+ # First publish
116
+ ack1 = mock_jts.publish('subject', data, header: { 'nats-msg-id' => 'id-1' })
117
+ expect(ack1.duplicate?).to be false
118
+
119
+ # Duplicate publish
120
+ ack2 = mock_jts.publish('subject', data, header: { 'nats-msg-id' => 'id-1' })
121
+ expect(ack2.duplicate?).to be true
122
+ ```
123
+
124
+ ### Consuming Events
125
+
126
+ Create subscriptions and fetch messages:
127
+
128
+ ```ruby
129
+ # Publish messages
130
+ 3.times do |i|
131
+ jetstream.publish(
132
+ 'test.subject',
133
+ { msg: i }.to_json,
134
+ header: { 'nats-msg-id' => "msg-#{i}" }
135
+ )
136
+ end
137
+
138
+ # Subscribe and fetch
139
+ subscription = jetstream.pull_subscribe('test.subject', 'consumer', stream: 'test-stream')
140
+ messages = subscription.fetch(10, timeout: 1)
141
+
142
+ expect(messages.size).to eq(3)
143
+
144
+ # Process and acknowledge
145
+ messages.each do |msg|
146
+ data = Oj.load(msg.data)
147
+ process(data)
148
+ msg.ack
149
+ end
150
+ ```
151
+
152
+ ### Message Acknowledgment
153
+
154
+ The mock supports all acknowledgment types:
155
+
156
+ ```ruby
157
+ message = subscription.fetch(1).first
158
+
159
+ # Positive acknowledgment (removes from queue)
160
+ message.ack
161
+
162
+ # Negative acknowledgment (keeps in queue for redelivery)
163
+ message.nak
164
+
165
+ # Terminate (removes from queue without processing)
166
+ message.term
167
+ ```
168
+
169
+ ### Redelivery and max_deliver
170
+
171
+ The mock respects `max_deliver` settings:
172
+
173
+ ```ruby
174
+ subscription = jetstream.pull_subscribe(
175
+ 'test.subject',
176
+ 'consumer',
177
+ stream: 'test-stream',
178
+ max_deliver: 3
179
+ )
180
+
181
+ # Publish a message
182
+ jetstream.publish('test.subject', 'data', header: { 'nats-msg-id' => 'id-1' })
183
+
184
+ # Attempt 1
185
+ msg = subscription.fetch(1).first
186
+ expect(msg.metadata.num_delivered).to eq(1)
187
+ msg.nak
188
+
189
+ # Attempt 2
190
+ msg = subscription.fetch(1).first
191
+ expect(msg.metadata.num_delivered).to eq(2)
192
+ msg.nak
193
+
194
+ # Attempt 3
195
+ msg = subscription.fetch(1).first
196
+ expect(msg.metadata.num_delivered).to eq(3)
197
+ msg.nak
198
+
199
+ # Attempt 4 - empty (exceeded max_deliver)
200
+ msgs = subscription.fetch(1)
201
+ expect(msgs).to be_empty
202
+ ```
203
+
204
+ ### Stream and Consumer Management
205
+
206
+ ```ruby
207
+ # Add a stream
208
+ jetstream.add_stream(
209
+ name: 'my-stream',
210
+ subjects: ['my.subject.>']
211
+ )
212
+
213
+ # Get stream info
214
+ info = jetstream.stream_info('my-stream')
215
+ expect(info.config.name).to eq('my-stream')
216
+
217
+ # Get consumer info
218
+ subscription = jetstream.pull_subscribe('my.subject', 'consumer', stream: 'my-stream')
219
+ info = jetstream.consumer_info('my-stream', 'consumer')
220
+ expect(info.name).to eq('consumer')
221
+ ```
222
+
223
+ ### Connection Lifecycle
224
+
225
+ Simulate connection events:
226
+
227
+ ```ruby
228
+ mock_connection = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
229
+
230
+ # Register callbacks
231
+ reconnect_called = false
232
+ mock_connection.on_reconnect { reconnect_called = true }
233
+
234
+ # Simulate events
235
+ mock_connection.simulate_disconnect!
236
+ expect(mock_connection.connected?).to be false
237
+
238
+ mock_connection.simulate_reconnect!
239
+ expect(reconnect_called).to be true
240
+ ```
241
+
242
+ ### Error Scenarios
243
+
244
+ The mock can simulate various error conditions:
245
+
246
+ ```ruby
247
+ # JetStream not available
248
+ disconnected_conn = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
249
+ expect { disconnected_conn.jetstream }.to raise_error(NATS::IO::NoRespondersError)
250
+
251
+ # Stream not found
252
+ expect do
253
+ jetstream.stream_info('nonexistent')
254
+ end.to raise_error(NATS::JetStream::Error, 'stream not found')
255
+
256
+ # Consumer not found
257
+ expect do
258
+ jetstream.consumer_info('test-stream', 'nonexistent')
259
+ end.to raise_error(NATS::JetStream::Error, 'consumer not found')
260
+ ```
261
+
262
+ ## Integration with JetstreamBridge
263
+
264
+ ### Full Publishing Flow
265
+
266
+ ```ruby
267
+ before do
268
+ JetstreamBridge.reset!
269
+
270
+ mock_conn = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
271
+ mock_jts = mock_conn.jetstream
272
+
273
+ allow(NATS::IO::Client).to receive(:new).and_return(mock_conn)
274
+ allow(JetstreamBridge::Connection).to receive(:connect!).and_call_original
275
+
276
+ # Setup stream
277
+ mock_jts.add_stream(
278
+ name: 'test-jetstream-bridge-stream',
279
+ subjects: ['test.>']
280
+ )
281
+
282
+ # Allow topology check to succeed
283
+ allow(JetstreamBridge::Topology).to receive(:ensure!)
284
+
285
+ JetstreamBridge.configure do |config|
286
+ config.env = 'test'
287
+ config.app_name = 'api'
288
+ config.destination_app = 'worker'
289
+ end
290
+ end
291
+
292
+ it 'publishes through JetstreamBridge' do
293
+ result = JetstreamBridge.publish(
294
+ event_type: 'user.created',
295
+ resource_type: 'user',
296
+ payload: { id: 1, name: 'Test' }
297
+ )
298
+
299
+ expect(result).to be_publish_success
300
+ expect(result.event_id).to be_present
301
+ expect(result.subject).to eq('test.api.sync.worker')
302
+
303
+ # Verify in storage
304
+ storage = JetstreamBridge::TestHelpers.mock_storage
305
+ expect(storage.messages.size).to eq(1)
306
+ end
307
+ ```
308
+
309
+ ### Full Consuming Flow
310
+
311
+ ```ruby
312
+ it 'consumes through JetstreamBridge' do
313
+ mock_conn = JetstreamBridge::TestHelpers.mock_connection
314
+ mock_jts = mock_conn.jetstream
315
+
316
+ # Publish message to destination subject
317
+ mock_jts.publish(
318
+ 'test.worker.sync.api',
319
+ Oj.dump({
320
+ 'event_id' => 'event-1',
321
+ 'schema_version' => 1,
322
+ 'event_type' => 'task.created',
323
+ 'producer' => 'api',
324
+ 'resource_id' => '1',
325
+ 'resource_type' => 'task',
326
+ 'occurred_at' => Time.now.utc.iso8601,
327
+ 'trace_id' => SecureRandom.hex(8),
328
+ 'payload' => { id: 1, title: 'Task 1' }
329
+ }),
330
+ header: { 'nats-msg-id' => 'event-1' }
331
+ )
332
+
333
+ # Create consumer
334
+ events_received = []
335
+ consumer = JetstreamBridge::Consumer.new(batch_size: 10) do |event|
336
+ events_received << event
337
+ end
338
+
339
+ # Mock subscription
340
+ subscription = mock_jts.pull_subscribe(
341
+ 'test.worker.sync.api',
342
+ 'test-consumer',
343
+ stream: 'test-jetstream-bridge-stream'
344
+ )
345
+
346
+ allow_any_instance_of(JetstreamBridge::SubscriptionManager)
347
+ .to receive(:subscribe!)
348
+ .and_return(subscription)
349
+
350
+ # Process one batch
351
+ allow(consumer).to receive(:stop_requested?).and_return(false, true)
352
+ consumer.run!
353
+
354
+ expect(events_received.size).to eq(1)
355
+ expect(events_received.first.type).to eq('task.created')
356
+ end
357
+ ```
358
+
359
+ ## Direct Storage Access
360
+
361
+ For advanced testing, you can access the mock storage directly:
362
+
363
+ ```ruby
364
+ storage = JetstreamBridge::TestHelpers.mock_storage
365
+
366
+ # Inspect all messages
367
+ storage.messages.each do |msg|
368
+ puts "Subject: #{msg[:subject]}"
369
+ puts "Data: #{msg[:data]}"
370
+ puts "Delivery count: #{msg[:delivery_count]}"
371
+ end
372
+
373
+ # Check streams
374
+ storage.streams.each do |name, stream|
375
+ puts "Stream: #{name}"
376
+ end
377
+
378
+ # Check consumers
379
+ storage.consumers.each do |name, consumer|
380
+ puts "Consumer: #{name} on stream #{consumer.stream}"
381
+ end
382
+
383
+ # Reset storage
384
+ storage.reset!
385
+ ```
386
+
387
+ ## Best Practices
388
+
389
+ 1. **Reset state between tests**: Always call `reset_test_mode!` in your `after` hooks
390
+ 2. **Use test helpers when possible**: They handle most of the mocking setup for you
391
+ 3. **Test both success and failure paths**: Use the mock to simulate errors
392
+ 4. **Verify message content**: Check that envelopes are correctly formatted
393
+ 5. **Test idempotency**: Verify duplicate detection and redelivery behavior
394
+ 6. **Mock topology setup**: Remember to stub `JetstreamBridge::Topology.ensure!`
395
+
396
+ ## Examples
397
+
398
+ See the comprehensive test examples in:
399
+
400
+ - [spec/test_helpers/mock_nats_spec.rb](../spec/test_helpers/mock_nats_spec.rb)
401
+ - [spec/integration/mock_integration_spec.rb](../spec/integration/mock_integration_spec.rb)
402
+ - [spec/integration/mock_connection_integration_spec.rb](../spec/integration/mock_connection_integration_spec.rb)
403
+
404
+ ## Limitations
405
+
406
+ The mock is designed for testing and has some simplifications:
407
+
408
+ - No actual network operations
409
+ - Simplified stream subject matching (uses first stream)
410
+ - No persistent storage between test runs
411
+ - Simplified timing/timeout behavior
412
+ - No cluster or failover simulation
413
+
414
+ For integration tests that require a real NATS server, consider using Docker to run NATS in CI/CD environments.
@@ -101,12 +101,19 @@ module JetstreamBridge
101
101
  @batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
102
102
  @durable = durable_name || JetstreamBridge.config.durable_name
103
103
  @idle_backoff = IDLE_SLEEP_SECS
104
- @running = true
104
+ @reconnect_attempts = 0
105
+ @running = true
105
106
  @shutdown_requested = false
106
- @jts = Connection.connect!
107
+ @start_time = Time.now
108
+ @iterations = 0
109
+ @last_health_check = Time.now
110
+ # Use existing connection (should already be established)
111
+ @jts = Connection.jetstream
112
+ raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
113
+
107
114
  @middleware_chain = MiddlewareChain.new
108
115
 
109
- ensure_destination!
116
+ ensure_destination_app_configured!
110
117
 
111
118
  @sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
112
119
  @processor = MessageProcessor.new(@jts, @handler, middleware_chain: @middleware_chain)
@@ -192,6 +199,11 @@ module JetstreamBridge
192
199
  while @running
193
200
  processed = process_batch
194
201
  idle_sleep(processed)
202
+
203
+ @iterations += 1
204
+
205
+ # Periodic health checks every 10 minutes (600 seconds)
206
+ perform_health_check_if_due
195
207
  end
196
208
 
197
209
  # Drain in-flight messages before exiting
@@ -232,7 +244,7 @@ module JetstreamBridge
232
244
 
233
245
  private
234
246
 
235
- def ensure_destination!
247
+ def ensure_destination_app_configured!
236
248
  return unless JetstreamBridge.config.destination_app.to_s.empty?
237
249
 
238
250
  raise ArgumentError, 'destination_app must be configured'
@@ -274,22 +286,40 @@ module JetstreamBridge
274
286
  rescue StandardError => e
275
287
  # Safety: never let a single bad message kill the batch loop.
276
288
  Logging.error("Message processing crashed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
289
+ safe_nak_message(msg)
277
290
  0
278
291
  end
279
292
 
280
293
  def handle_js_error(error)
281
294
  if recoverable_consumer_error?(error)
295
+ # Increment reconnect attempts and calculate exponential backoff
296
+ @reconnect_attempts += 1
297
+ backoff_secs = calculate_reconnect_backoff(@reconnect_attempts)
298
+
282
299
  Logging.warn(
283
- "Recovering subscription after error: #{error.class} #{error.message}",
300
+ "Recovering subscription after error (attempt #{@reconnect_attempts}): " \
301
+ "#{error.class} #{error.message}, waiting #{backoff_secs}s",
284
302
  tag: 'JetstreamBridge::Consumer'
285
303
  )
304
+
305
+ sleep(backoff_secs)
286
306
  ensure_subscription!
307
+
308
+ # Reset counter on successful reconnection
309
+ @reconnect_attempts = 0
287
310
  else
288
311
  Logging.error("Fetch failed (non-recoverable): #{error.class} #{error.message}", tag: 'JetstreamBridge::Consumer')
289
312
  end
290
313
  0
291
314
  end
292
315
 
316
+ def calculate_reconnect_backoff(attempt)
317
+ # Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to 30s max
318
+ base_delay = 0.1
319
+ max_delay = 30.0
320
+ [base_delay * (2**(attempt - 1)), max_delay].min
321
+ end
322
+
293
323
  def recoverable_consumer_error?(error)
294
324
  msg = error.message.to_s
295
325
  code = js_err_code(msg)
@@ -327,6 +357,63 @@ module JetstreamBridge
327
357
  Logging.debug("Could not set up signal handlers: #{e.message}", tag: 'JetstreamBridge::Consumer')
328
358
  end
329
359
 
360
+ def perform_health_check_if_due
361
+ now = Time.now
362
+ time_since_check = now - @last_health_check
363
+
364
+ return unless time_since_check >= 600 # 10 minutes
365
+
366
+ @last_health_check = now
367
+ uptime = now - @start_time
368
+ memory_mb = memory_usage_mb
369
+
370
+ Logging.info(
371
+ "Consumer health: iterations=#{@iterations}, " \
372
+ "memory=#{memory_mb}MB, uptime=#{uptime.round}s",
373
+ tag: 'JetstreamBridge::Consumer'
374
+ )
375
+
376
+ # Warn if memory usage is high (over 1GB)
377
+ if memory_mb > 1000
378
+ Logging.warn(
379
+ "High memory usage detected: #{memory_mb}MB",
380
+ tag: 'JetstreamBridge::Consumer'
381
+ )
382
+ end
383
+
384
+ # Suggest GC if heap is growing significantly
385
+ suggest_gc_if_needed
386
+ rescue StandardError => e
387
+ Logging.debug(
388
+ "Health check failed: #{e.class} #{e.message}",
389
+ tag: 'JetstreamBridge::Consumer'
390
+ )
391
+ end
392
+
393
+ def memory_usage_mb
394
+ # Get memory usage from OS (works on Linux/macOS)
395
+ rss_kb = `ps -o rss= -p #{Process.pid}`.to_i
396
+ rss_kb / 1024.0
397
+ rescue StandardError
398
+ 0.0
399
+ end
400
+
401
+ def suggest_gc_if_needed
402
+ # Suggest GC if heap has many live slots (Ruby-specific optimization)
403
+ return unless defined?(GC) && GC.respond_to?(:stat)
404
+
405
+ stats = GC.stat
406
+ heap_live_slots = stats[:heap_live_slots] || stats['heap_live_slots'] || 0
407
+
408
+ # Suggest GC if we have over 100k live objects
409
+ GC.start if heap_live_slots > 100_000
410
+ rescue StandardError => e
411
+ Logging.debug(
412
+ "GC check failed: #{e.class} #{e.message}",
413
+ tag: 'JetstreamBridge::Consumer'
414
+ )
415
+ end
416
+
330
417
  def drain_inflight_messages
331
418
  return unless @psub
332
419
 
@@ -347,5 +434,14 @@ module JetstreamBridge
347
434
  rescue StandardError => e
348
435
  Logging.error("Drain failed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
349
436
  end
437
+
438
+ def safe_nak_message(msg)
439
+ return unless msg.respond_to?(:nak)
440
+
441
+ msg.nak
442
+ rescue StandardError => e
443
+ Logging.error("Failed to NAK message after crash: #{e.class} #{e.message}",
444
+ tag: 'JetstreamBridge::Consumer')
445
+ end
350
446
  end
351
447
  end
@@ -27,13 +27,27 @@ module JetstreamBridge
27
27
  end
28
28
 
29
29
  repo.persist_pre(record, msg)
30
- @processor.handle_message(msg)
31
- repo.persist_post(record)
32
- true
30
+ action = @processor.handle_message(msg, auto_ack: false)
31
+
32
+ case action&.action
33
+ when :ack
34
+ repo.persist_post(record)
35
+ @processor.send(:apply_action, msg, action)
36
+ true
37
+ when :nak
38
+ repo.persist_failure(record, action.error || StandardError.new('Inbox processing failed'))
39
+ @processor.send(:apply_action, msg, action)
40
+ false
41
+ else
42
+ repo.persist_failure(record, StandardError.new('Inbox processing returned no action'))
43
+ false
44
+ end
33
45
  rescue StandardError => e
34
46
  repo.persist_failure(record, e) if repo && record
35
47
  Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
36
48
  tag: 'JetstreamBridge::Consumer')
49
+ # Ensure the message is retried if possible
50
+ @processor.send(:safe_nak, msg, nil, e, delay: nil) if msg.respond_to?(:nak)
37
51
  false
38
52
  end
39
53
 
@@ -11,13 +11,15 @@ module JetstreamBridge
11
11
  end
12
12
 
13
13
  def find_or_build(msg)
14
- if ModelUtils.has_columns?(@klass, :event_id)
15
- @klass.find_or_initialize_by(event_id: msg.event_id)
16
- elsif ModelUtils.has_columns?(@klass, :stream_seq)
17
- @klass.find_or_initialize_by(stream_seq: msg.seq)
18
- else
19
- @klass.new
20
- end
14
+ record = if ModelUtils.has_columns?(@klass, :event_id)
15
+ @klass.find_or_initialize_by(event_id: msg.event_id)
16
+ elsif ModelUtils.has_columns?(@klass, :stream_seq)
17
+ @klass.find_or_initialize_by(stream_seq: msg.seq)
18
+ else
19
+ @klass.new
20
+ end
21
+
22
+ lock_record(record)
21
23
  end
22
24
 
23
25
  def already_processed?(record)
@@ -74,5 +76,15 @@ module JetstreamBridge
74
76
  Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
75
77
  tag: 'JetstreamBridge::Consumer')
76
78
  end
79
+
80
+ def lock_record(record)
81
+ return record unless record.respond_to?(:persisted?) && record.persisted?
82
+ return record unless record.respond_to?(:lock!)
83
+
84
+ record.lock!
85
+ record
86
+ rescue ActiveRecord::RecordNotFound
87
+ @klass.new
88
+ end
77
89
  end
78
90
  end