jetstream_bridge 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -0
- data/README.md +25 -1426
- data/docs/GETTING_STARTED.md +97 -0
- data/docs/PRODUCTION.md +503 -0
- data/docs/TESTING.md +414 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +16 -4
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +17 -3
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +19 -7
- data/lib/jetstream_bridge/consumer/message_processor.rb +88 -52
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +24 -15
- data/lib/jetstream_bridge/core/bridge_helpers.rb +90 -0
- data/lib/jetstream_bridge/core/connection.rb +21 -5
- data/lib/jetstream_bridge/core.rb +8 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +2 -2
- data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
- data/lib/jetstream_bridge/publisher/publisher.rb +8 -6
- data/lib/jetstream_bridge/rails/integration.rb +148 -0
- data/lib/jetstream_bridge/rails/railtie.rb +53 -0
- data/lib/jetstream_bridge/rails.rb +5 -0
- data/lib/jetstream_bridge/tasks/install.rake +1 -1
- data/lib/jetstream_bridge/test_helpers/fixtures.rb +41 -0
- data/lib/jetstream_bridge/test_helpers/integration_helpers.rb +77 -0
- data/lib/jetstream_bridge/test_helpers/matchers.rb +98 -0
- data/lib/jetstream_bridge/test_helpers.rb +4 -259
- data/lib/jetstream_bridge/topology/stream.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +56 -97
- metadata +21 -8
- data/lib/jetstream_bridge/railtie.rb +0 -91
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.
|
|
@@ -107,11 +107,13 @@ module JetstreamBridge
|
|
|
107
107
|
@start_time = Time.now
|
|
108
108
|
@iterations = 0
|
|
109
109
|
@last_health_check = Time.now
|
|
110
|
-
# Use existing connection
|
|
111
|
-
@jts = Connection.jetstream
|
|
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
|
+
|
|
112
114
|
@middleware_chain = MiddlewareChain.new
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
ensure_destination_app_configured!
|
|
115
117
|
|
|
116
118
|
@sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
|
|
117
119
|
@processor = MessageProcessor.new(@jts, @handler, middleware_chain: @middleware_chain)
|
|
@@ -242,7 +244,7 @@ module JetstreamBridge
|
|
|
242
244
|
|
|
243
245
|
private
|
|
244
246
|
|
|
245
|
-
def
|
|
247
|
+
def ensure_destination_app_configured!
|
|
246
248
|
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
|
247
249
|
|
|
248
250
|
raise ArgumentError, 'destination_app must be configured'
|
|
@@ -284,6 +286,7 @@ module JetstreamBridge
|
|
|
284
286
|
rescue StandardError => e
|
|
285
287
|
# Safety: never let a single bad message kill the batch loop.
|
|
286
288
|
Logging.error("Message processing crashed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
289
|
+
safe_nak_message(msg)
|
|
287
290
|
0
|
|
288
291
|
end
|
|
289
292
|
|
|
@@ -431,5 +434,14 @@ module JetstreamBridge
|
|
|
431
434
|
rescue StandardError => e
|
|
432
435
|
Logging.error("Drain failed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
|
|
433
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
|
|
434
446
|
end
|
|
435
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|