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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +106 -0
- data/README.md +22 -1402
- data/docs/GETTING_STARTED.md +92 -0
- data/docs/PRODUCTION.md +503 -0
- data/docs/TESTING.md +414 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +101 -5
- 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 +85 -0
- data/lib/jetstream_bridge/core/config.rb +27 -4
- data/lib/jetstream_bridge/core/connection.rb +162 -13
- data/lib/jetstream_bridge/core.rb +8 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +13 -7
- data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
- data/lib/jetstream_bridge/publisher/publisher.rb +10 -5
- data/lib/jetstream_bridge/rails/integration.rb +153 -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/mock_nats.rb +524 -0
- data/lib/jetstream_bridge/test_helpers.rb +85 -121
- data/lib/jetstream_bridge/topology/overlap_guard.rb +46 -0
- data/lib/jetstream_bridge/topology/stream.rb +7 -4
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +138 -63
- metadata +32 -12
- 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
|
-
@
|
|
104
|
+
@reconnect_attempts = 0
|
|
105
|
+
@running = true
|
|
105
106
|
@shutdown_requested = false
|
|
106
|
-
@
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|