jetstream_bridge 7.1.3 → 7.1.4
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 +7 -0
- data/docs/API.md +37 -40
- data/docs/ARCHITECTURE.md +45 -38
- data/docs/PRODUCTION.md +3 -5
- data/lib/jetstream_bridge/consumer/consumer.rb +18 -3
- data/lib/jetstream_bridge/consumer/consumer_state.rb +35 -3
- data/lib/jetstream_bridge/consumer/message_processor.rb +55 -3
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +21 -2
- data/lib/jetstream_bridge/core/config.rb +8 -2
- data/lib/jetstream_bridge/core/connection.rb +13 -2
- data/lib/jetstream_bridge/core/duration.rb +27 -8
- data/lib/jetstream_bridge/core/logging.rb +26 -0
- data/lib/jetstream_bridge/errors.rb +74 -16
- data/lib/jetstream_bridge/models/event.rb +34 -2
- data/lib/jetstream_bridge/models/event_envelope.rb +32 -3
- data/lib/jetstream_bridge/publisher/batch_publisher.rb +5 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +2 -2
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +23 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c558d8852741ad943ccc0028d906690da3495114a144f14319e61ee00bf5fb1e
|
|
4
|
+
data.tar.gz: 86aafa9188755f32a71de67b356f99911585c166bbc93450454677330c46f1ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 41f0d7e69e12f60d901e1fef56752bf26d2809e317b63ddbc45eeac1126855ea11b601347f5faed749e1ea04fa612746e84f3ac0173361d5bc22874c5eb186ee
|
|
7
|
+
data.tar.gz: 69e8caf822d7591a4e34a94c9bbdf000509d0261e09ac19e49ea402061735d433709e0d2e2328eee8979c6300f6bbe3f7a8055de0097f79d97ffae4d1bb5af72
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [7.1.4] - 2026-02-13
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Documentation accuracy** - Fixed ~20 inaccuracies in `docs/` where method names, parameters, and examples had drifted from the source.
|
|
13
|
+
- **YARD inline docs** - Added `@param`, `@return`, `@raise` tags across all public API surfaces.
|
|
14
|
+
|
|
8
15
|
## [7.1.3] - 2026-02-13
|
|
9
16
|
|
|
10
17
|
### Fixed
|
data/docs/API.md
CHANGED
|
@@ -34,7 +34,6 @@ JetstreamBridge.configure do |config|
|
|
|
34
34
|
config.use_dlq = true # Dead letter queue for poison messages
|
|
35
35
|
|
|
36
36
|
# Consumer settings
|
|
37
|
-
config.durable_name = "#{app_name}-workers"
|
|
38
37
|
config.max_deliver = 5 # Max delivery attempts
|
|
39
38
|
config.ack_wait = "30s" # Time to wait for ACK
|
|
40
39
|
config.backoff = ["1s", "5s", "15s", "30s", "60s"]
|
|
@@ -51,7 +50,7 @@ end
|
|
|
51
50
|
|
|
52
51
|
### `JetstreamBridge.config`
|
|
53
52
|
|
|
54
|
-
Returns the current configuration object
|
|
53
|
+
Returns the current configuration object, creating a default instance if needed.
|
|
55
54
|
|
|
56
55
|
```ruby
|
|
57
56
|
stream_name = JetstreamBridge.config.stream_name
|
|
@@ -67,6 +66,8 @@ Explicitly start the connection and provision topology (if `auto_provision=true`
|
|
|
67
66
|
JetstreamBridge.startup!
|
|
68
67
|
```
|
|
69
68
|
|
|
69
|
+
**Raises:** `ConfigurationError`, `ConnectionError`
|
|
70
|
+
|
|
70
71
|
**Note:** Rails applications auto-start after initialization. Non-Rails apps should call this manually or rely on auto-connect on first publish/subscribe.
|
|
71
72
|
|
|
72
73
|
### `JetstreamBridge.shutdown!`
|
|
@@ -79,7 +80,7 @@ JetstreamBridge.shutdown!
|
|
|
79
80
|
|
|
80
81
|
### `JetstreamBridge.reset!`
|
|
81
82
|
|
|
82
|
-
Reset all internal state (for testing).
|
|
83
|
+
Reset all internal state (for testing). Also resets consumer signal handlers.
|
|
83
84
|
|
|
84
85
|
```ruby
|
|
85
86
|
JetstreamBridge.reset!
|
|
@@ -95,14 +96,12 @@ Publish an event to the destination app.
|
|
|
95
96
|
JetstreamBridge.publish(
|
|
96
97
|
event_type: "user.created", # Required
|
|
97
98
|
resource_type: "user", # Required
|
|
98
|
-
resource_id: user.id, # Optional
|
|
99
99
|
payload: { id: user.id, email: user.email },
|
|
100
|
-
headers: { correlation_id: "..." }, # Optional
|
|
101
100
|
event_id: "custom-uuid" # Optional (auto-generated)
|
|
102
101
|
)
|
|
103
102
|
```
|
|
104
103
|
|
|
105
|
-
**Returns:** `
|
|
104
|
+
**Returns:** `Models::PublishResult`
|
|
106
105
|
|
|
107
106
|
**With Outbox:**
|
|
108
107
|
|
|
@@ -131,11 +130,11 @@ Publish multiple events efficiently.
|
|
|
131
130
|
```ruby
|
|
132
131
|
results = JetstreamBridge.publish_batch do |batch|
|
|
133
132
|
users.each do |user|
|
|
134
|
-
batch.
|
|
133
|
+
batch.add(event_type: "user.created", resource_type: "user", payload: user)
|
|
135
134
|
end
|
|
136
135
|
end
|
|
137
136
|
|
|
138
|
-
puts "Published: #{results.
|
|
137
|
+
puts "Published: #{results.successful_count}, Failed: #{results.failed_count}"
|
|
139
138
|
```
|
|
140
139
|
|
|
141
140
|
## Consuming
|
|
@@ -155,8 +154,8 @@ end
|
|
|
155
154
|
|
|
156
155
|
```ruby
|
|
157
156
|
consumer = JetstreamBridge::Consumer.new(
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
durable_name: "my-consumer", # Override default durable name
|
|
158
|
+
batch_size: 10 # Process up to 10 messages at once
|
|
160
159
|
) do |event|
|
|
161
160
|
# ...
|
|
162
161
|
end
|
|
@@ -184,14 +183,13 @@ The event object passed to your handler:
|
|
|
184
183
|
|
|
185
184
|
```ruby
|
|
186
185
|
event.event_id # => "evt_123"
|
|
187
|
-
event.
|
|
186
|
+
event.type # => "user.created"
|
|
188
187
|
event.resource_type # => "user"
|
|
189
188
|
event.resource_id # => "456"
|
|
190
|
-
event.payload # =>
|
|
191
|
-
event.headers # => { "correlation_id" => "..." }
|
|
189
|
+
event.payload # => PayloadAccessor (supports method-style: event.payload.email)
|
|
192
190
|
event.subject # => "source_app.sync.my_app"
|
|
193
191
|
event.stream # => "jetstream-bridge-stream"
|
|
194
|
-
event.
|
|
192
|
+
event.sequence # => 123
|
|
195
193
|
event.deliveries # => 1
|
|
196
194
|
```
|
|
197
195
|
|
|
@@ -255,13 +253,18 @@ Get comprehensive health status.
|
|
|
255
253
|
```ruby
|
|
256
254
|
health = JetstreamBridge.health_check
|
|
257
255
|
|
|
258
|
-
health[:
|
|
259
|
-
health[:
|
|
260
|
-
health[:
|
|
261
|
-
health[:
|
|
262
|
-
health[:
|
|
263
|
-
health[:
|
|
264
|
-
health[:
|
|
256
|
+
health[:healthy] # => true/false
|
|
257
|
+
health[:connection][:state] # => :connected
|
|
258
|
+
health[:connection][:connected] # => true/false
|
|
259
|
+
health[:connection][:connected_at] # => "2025-01-01T00:00:00Z"
|
|
260
|
+
health[:stream][:exists] # => true/false
|
|
261
|
+
health[:stream][:name] # => "jetstream-bridge-stream"
|
|
262
|
+
health[:stream][:subjects] # => ["app.sync.worker"]
|
|
263
|
+
health[:stream][:messages] # => 123
|
|
264
|
+
health[:performance][:nats_rtt_ms] # => 1.2
|
|
265
|
+
health[:performance][:health_check_duration_ms] # => 45.2
|
|
266
|
+
health[:config] # => { app_name:, stream_name:, ... }
|
|
267
|
+
health[:version] # => "7.0.0"
|
|
265
268
|
```
|
|
266
269
|
|
|
267
270
|
### `JetstreamBridge.stream_info`
|
|
@@ -280,18 +283,6 @@ info[:last_seq] # => 1000
|
|
|
280
283
|
info[:consumer_count] # => 2
|
|
281
284
|
```
|
|
282
285
|
|
|
283
|
-
### `JetstreamBridge.connection_info`
|
|
284
|
-
|
|
285
|
-
Get NATS connection details.
|
|
286
|
-
|
|
287
|
-
```ruby
|
|
288
|
-
info = JetstreamBridge.connection_info
|
|
289
|
-
|
|
290
|
-
info[:connected] # => true
|
|
291
|
-
info[:servers] # => ["nats://localhost:4222"]
|
|
292
|
-
info[:connected_at] # => 2024-01-29 12:00:00 UTC
|
|
293
|
-
```
|
|
294
|
-
|
|
295
286
|
## Models
|
|
296
287
|
|
|
297
288
|
### `JetstreamBridge::OutboxEvent`
|
|
@@ -383,17 +374,23 @@ rescue JetstreamBridge::StreamNotFoundError => e
|
|
|
383
374
|
end
|
|
384
375
|
```
|
|
385
376
|
|
|
386
|
-
### Custom Error
|
|
377
|
+
### Custom Error Handling (Middleware)
|
|
387
378
|
|
|
388
379
|
```ruby
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
380
|
+
class SentryErrorMiddleware
|
|
381
|
+
def call(event)
|
|
382
|
+
yield
|
|
383
|
+
rescue StandardError => e
|
|
384
|
+
logger.error("Failed to process event #{event.event_id}: #{e.message}")
|
|
385
|
+
Sentry.capture_exception(e, extra: { event_id: event.event_id })
|
|
386
|
+
raise # Re-raise so the consumer can NAK/DLQ as appropriate
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
consumer = JetstreamBridge::Consumer.new do |event|
|
|
395
391
|
# Process event
|
|
396
392
|
end
|
|
393
|
+
consumer.use(SentryErrorMiddleware.new)
|
|
397
394
|
```
|
|
398
395
|
|
|
399
396
|
## Testing
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -68,12 +68,11 @@ Thread-safe singleton managing NATS connections:
|
|
|
68
68
|
|
|
69
69
|
**Key Methods:**
|
|
70
70
|
|
|
71
|
-
- `Connection.
|
|
72
|
-
- `
|
|
73
|
-
- `
|
|
74
|
-
- `connection.
|
|
75
|
-
- `connection.
|
|
76
|
-
- `connection.reconnect!` - Force reconnection
|
|
71
|
+
- `Connection.connect!` - Thread-safe connection establishment (class-level)
|
|
72
|
+
- `Connection.nc` - Access raw NATS client (class-level)
|
|
73
|
+
- `Connection.jetstream` - Access JetStream context (class-level)
|
|
74
|
+
- `connection.connected?` - Check connection health (cached, 30s TTL)
|
|
75
|
+
- `connection.state` - Get current state (:disconnected, :connecting, :connected, :reconnecting, :failed)
|
|
77
76
|
|
|
78
77
|
### Publisher (`lib/jetstream_bridge/publisher/publisher.rb`)
|
|
79
78
|
|
|
@@ -104,9 +103,9 @@ JetstreamBridge.publish(
|
|
|
104
103
|
"resource_type": "user",
|
|
105
104
|
"resource_id": "1",
|
|
106
105
|
"payload": { "id": 1, "email": "user@example.com" },
|
|
107
|
-
"
|
|
106
|
+
"occurred_at": "2024-01-01T00:00:00Z",
|
|
108
107
|
"producer": "api",
|
|
109
|
-
"schema_version":
|
|
108
|
+
"schema_version": 1,
|
|
110
109
|
"trace_id": "trace-uuid"
|
|
111
110
|
}
|
|
112
111
|
```
|
|
@@ -836,16 +835,16 @@ end
|
|
|
836
835
|
|
|
837
836
|
```ruby
|
|
838
837
|
class CustomErrorHandler
|
|
839
|
-
def call(event
|
|
840
|
-
|
|
838
|
+
def call(event)
|
|
839
|
+
yield
|
|
841
840
|
rescue CustomRetryableError => e
|
|
842
|
-
#
|
|
843
|
-
|
|
841
|
+
# Re-raise to let the consumer NAK with backoff
|
|
842
|
+
raise
|
|
844
843
|
rescue CustomPermanentError => e
|
|
845
844
|
# Log and move to DLQ
|
|
846
845
|
logger.error("Permanent error: #{e.message}")
|
|
847
846
|
publish_to_custom_dlq(event, e)
|
|
848
|
-
|
|
847
|
+
# Don't re-raise — consumer will ACK
|
|
849
848
|
end
|
|
850
849
|
end
|
|
851
850
|
|
|
@@ -858,19 +857,27 @@ consumer.use(CustomErrorHandler.new)
|
|
|
858
857
|
|
|
859
858
|
### Connection Singleton
|
|
860
859
|
|
|
861
|
-
**Thread-safe initialization:**
|
|
860
|
+
**Thread-safe initialization (Singleton + class-level mutex):**
|
|
862
861
|
|
|
863
862
|
```ruby
|
|
864
|
-
|
|
863
|
+
class Connection
|
|
864
|
+
include Singleton
|
|
865
865
|
|
|
866
|
-
|
|
867
|
-
return @@connection if @@connection
|
|
866
|
+
@@connection_lock = Mutex.new
|
|
868
867
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
868
|
+
class << self
|
|
869
|
+
def connect!(verify_js: nil)
|
|
870
|
+
@@connection_lock.synchronize { instance.connect!(verify_js: verify_js) }
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def nc
|
|
874
|
+
instance.__send__(:nc)
|
|
875
|
+
end
|
|
872
876
|
|
|
873
|
-
|
|
877
|
+
def jetstream
|
|
878
|
+
instance.__send__(:jetstream)
|
|
879
|
+
end
|
|
880
|
+
end
|
|
874
881
|
end
|
|
875
882
|
```
|
|
876
883
|
|
|
@@ -991,28 +998,30 @@ health = JetstreamBridge.health_check(skip_cache: false)
|
|
|
991
998
|
{
|
|
992
999
|
healthy: true,
|
|
993
1000
|
connection: {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
connected_at: "
|
|
1001
|
+
state: :connected,
|
|
1002
|
+
connected: true,
|
|
1003
|
+
connected_at: "2025-01-01T00:00:00Z"
|
|
1004
|
+
},
|
|
1005
|
+
stream: {
|
|
1006
|
+
exists: true,
|
|
1007
|
+
name: "jetstream-bridge-stream",
|
|
1008
|
+
subjects: ["app.sync.worker"],
|
|
1009
|
+
messages: 1523
|
|
997
1010
|
},
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
memory_bytes: 104857600,
|
|
1002
|
-
storage_bytes: 1073741824
|
|
1011
|
+
performance: {
|
|
1012
|
+
nats_rtt_ms: 2.5,
|
|
1013
|
+
health_check_duration_ms: 45.2
|
|
1003
1014
|
},
|
|
1004
1015
|
config: {
|
|
1005
|
-
stream_name: "jetstream-bridge-stream",
|
|
1006
1016
|
app_name: "api",
|
|
1007
1017
|
destination_app: "worker",
|
|
1018
|
+
stream_name: "jetstream-bridge-stream",
|
|
1019
|
+
auto_provision: true,
|
|
1008
1020
|
use_outbox: true,
|
|
1009
1021
|
use_inbox: true,
|
|
1010
1022
|
use_dlq: true
|
|
1011
1023
|
},
|
|
1012
|
-
|
|
1013
|
-
message_processing_time_ms: 45.2,
|
|
1014
|
-
last_health_check_ms: 12.5
|
|
1015
|
-
}
|
|
1024
|
+
version: "7.0.0"
|
|
1016
1025
|
}
|
|
1017
1026
|
```
|
|
1018
1027
|
|
|
@@ -1046,15 +1055,13 @@ ERROR [JetstreamBridge::Consumer] Unrecoverable error: ArgumentError
|
|
|
1046
1055
|
|
|
1047
1056
|
```ruby
|
|
1048
1057
|
class MetricsMiddleware
|
|
1049
|
-
def call(event
|
|
1058
|
+
def call(event)
|
|
1050
1059
|
start = Time.now
|
|
1051
|
-
|
|
1060
|
+
yield
|
|
1052
1061
|
duration = Time.now - start
|
|
1053
1062
|
|
|
1054
1063
|
StatsD.increment('jetstream.messages.processed')
|
|
1055
1064
|
StatsD.histogram('jetstream.processing_time', duration)
|
|
1056
|
-
|
|
1057
|
-
result
|
|
1058
1065
|
rescue => e
|
|
1059
1066
|
StatsD.increment('jetstream.messages.failed', tags: ["error:#{e.class}"])
|
|
1060
1067
|
raise
|
data/docs/PRODUCTION.md
CHANGED
|
@@ -117,11 +117,6 @@ Optimize consumer configuration based on your workload:
|
|
|
117
117
|
|
|
118
118
|
```ruby
|
|
119
119
|
JetstreamBridge.configure do |config|
|
|
120
|
-
# Adjust batch size based on message processing time
|
|
121
|
-
# Larger = better throughput, smaller = lower latency
|
|
122
|
-
# Default: 25
|
|
123
|
-
config.batch_size = 50
|
|
124
|
-
|
|
125
120
|
# Increase max_deliver for critical messages
|
|
126
121
|
# Default: 5
|
|
127
122
|
config.max_deliver = 10
|
|
@@ -134,6 +129,9 @@ JetstreamBridge.configure do |config|
|
|
|
134
129
|
# Default: [1s, 5s, 15s, 30s, 60s]
|
|
135
130
|
config.backoff = %w[2s 10s 30s 60s 120s]
|
|
136
131
|
end
|
|
132
|
+
|
|
133
|
+
# batch_size is a Consumer.new parameter, not a config attribute:
|
|
134
|
+
consumer = JetstreamBridge::Consumer.new(handler, batch_size: 50)
|
|
137
135
|
```
|
|
138
136
|
|
|
139
137
|
### Consumer Best Practices
|
|
@@ -59,6 +59,10 @@ module JetstreamBridge
|
|
|
59
59
|
TimeoutMiddleware = ConsumerMiddleware::TimeoutMiddleware
|
|
60
60
|
|
|
61
61
|
class << self
|
|
62
|
+
# Register a consumer instance to receive OS signal notifications (INT, TERM).
|
|
63
|
+
#
|
|
64
|
+
# @param consumer [Consumer] Consumer to register
|
|
65
|
+
# @return [void]
|
|
62
66
|
def register_consumer_for_signals(consumer)
|
|
63
67
|
signal_registry_mutex.synchronize do
|
|
64
68
|
signal_consumers << consumer
|
|
@@ -66,10 +70,17 @@ module JetstreamBridge
|
|
|
66
70
|
end
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
# Remove a consumer from the signal registry.
|
|
74
|
+
#
|
|
75
|
+
# @param consumer [Consumer] Consumer to unregister
|
|
76
|
+
# @return [void]
|
|
69
77
|
def unregister_consumer_for_signals(consumer)
|
|
70
78
|
signal_registry_mutex.synchronize { signal_consumers.delete(consumer) }
|
|
71
79
|
end
|
|
72
80
|
|
|
81
|
+
# Clear all registered consumers and reset signal handler state.
|
|
82
|
+
#
|
|
83
|
+
# @return [void]
|
|
73
84
|
def reset_signal_handlers!
|
|
74
85
|
signal_registry_mutex.synchronize { signal_consumers.clear }
|
|
75
86
|
@signal_handlers_installed = false
|
|
@@ -141,13 +152,17 @@ module JetstreamBridge
|
|
|
141
152
|
attr_reader :batch_size
|
|
142
153
|
# @return [MiddlewareChain] Middleware chain for processing
|
|
143
154
|
attr_reader :middleware_chain
|
|
144
|
-
#
|
|
145
|
-
attr_reader :processing_state
|
|
155
|
+
# @return [ProcessingState] Processing counters and backoff state
|
|
156
|
+
attr_reader :processing_state
|
|
157
|
+
# @return [LifecycleState] Lifecycle flags and timing
|
|
158
|
+
attr_reader :lifecycle_state
|
|
159
|
+
# @return [ConnectionState] Reconnection attempts and health check timing
|
|
160
|
+
attr_reader :connection_state
|
|
146
161
|
|
|
147
162
|
# Initialize a new Consumer instance.
|
|
148
163
|
#
|
|
149
164
|
# @param handler [Proc, #call, nil] Message handler that processes events.
|
|
150
|
-
# Must respond to #call(event)
|
|
165
|
+
# Must respond to #call(event).
|
|
151
166
|
# @param durable_name [String, nil] Optional durable consumer name override.
|
|
152
167
|
# Defaults to config.durable_name.
|
|
153
168
|
# @param batch_size [Integer, nil] Number of messages to fetch per batch.
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module JetstreamBridge
|
|
4
4
|
class Consumer
|
|
5
|
-
# Tracks processing counters and backoff state.
|
|
5
|
+
# Tracks processing counters and idle-backoff state.
|
|
6
6
|
class ProcessingState
|
|
7
|
-
|
|
7
|
+
# @return [Float] Current idle backoff duration in seconds
|
|
8
|
+
attr_accessor :idle_backoff
|
|
9
|
+
# @return [Integer] Total number of processing loop iterations
|
|
10
|
+
attr_accessor :iterations
|
|
8
11
|
|
|
12
|
+
# @param idle_backoff [Float] Initial idle backoff duration (seconds)
|
|
13
|
+
# @param iterations [Integer] Starting iteration count
|
|
9
14
|
def initialize(idle_backoff:, iterations: 0)
|
|
10
15
|
@idle_backoff = idle_backoff
|
|
11
16
|
@iterations = iterations
|
|
@@ -14,9 +19,18 @@ module JetstreamBridge
|
|
|
14
19
|
|
|
15
20
|
# Tracks lifecycle flags and timing for the consumer.
|
|
16
21
|
class LifecycleState
|
|
17
|
-
|
|
22
|
+
# @return [Boolean] Whether the consumer loop should keep running
|
|
23
|
+
attr_accessor :running
|
|
24
|
+
# @return [Boolean] Whether a graceful shutdown has been requested
|
|
25
|
+
attr_accessor :shutdown_requested
|
|
26
|
+
# @return [String, nil] Signal name that triggered shutdown (e.g. "INT")
|
|
27
|
+
attr_accessor :signal_received
|
|
28
|
+
# @return [Boolean] Whether the signal receipt has been logged
|
|
29
|
+
attr_accessor :signal_logged
|
|
30
|
+
# @return [Time] When the consumer started
|
|
18
31
|
attr_reader :start_time
|
|
19
32
|
|
|
33
|
+
# @param start_time [Time] Consumer start time (defaults to now)
|
|
20
34
|
def initialize(start_time: Time.now)
|
|
21
35
|
@running = true
|
|
22
36
|
@shutdown_requested = false
|
|
@@ -25,16 +39,27 @@ module JetstreamBridge
|
|
|
25
39
|
@start_time = start_time
|
|
26
40
|
end
|
|
27
41
|
|
|
42
|
+
# Request a graceful shutdown.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
28
45
|
def stop!
|
|
29
46
|
@shutdown_requested = true
|
|
30
47
|
@running = false
|
|
31
48
|
end
|
|
32
49
|
|
|
50
|
+
# Record an OS signal and trigger shutdown.
|
|
51
|
+
#
|
|
52
|
+
# @param sig [String] Signal name (e.g. "INT", "TERM")
|
|
53
|
+
# @return [void]
|
|
33
54
|
def signal!(sig)
|
|
34
55
|
@signal_received = sig
|
|
35
56
|
stop!
|
|
36
57
|
end
|
|
37
58
|
|
|
59
|
+
# Calculate consumer uptime in seconds.
|
|
60
|
+
#
|
|
61
|
+
# @param now [Time] Current time
|
|
62
|
+
# @return [Float] Uptime in seconds
|
|
38
63
|
def uptime(now = Time.now)
|
|
39
64
|
now - @start_time
|
|
40
65
|
end
|
|
@@ -42,14 +67,21 @@ module JetstreamBridge
|
|
|
42
67
|
|
|
43
68
|
# Tracks reconnection attempts and health check timing.
|
|
44
69
|
class ConnectionState
|
|
70
|
+
# @return [Integer] Number of consecutive reconnection attempts
|
|
45
71
|
attr_accessor :reconnect_attempts
|
|
72
|
+
# @return [Time] When the last health check was performed
|
|
46
73
|
attr_reader :last_health_check
|
|
47
74
|
|
|
75
|
+
# @param now [Time] Initial health check timestamp
|
|
48
76
|
def initialize(now: Time.now)
|
|
49
77
|
@reconnect_attempts = 0
|
|
50
78
|
@last_health_check = now
|
|
51
79
|
end
|
|
52
80
|
|
|
81
|
+
# Record that a health check was performed.
|
|
82
|
+
#
|
|
83
|
+
# @param now [Time] Timestamp of the health check
|
|
84
|
+
# @return [void]
|
|
53
85
|
def mark_health_check(now = Time.now)
|
|
54
86
|
@last_health_check = now
|
|
55
87
|
end
|
|
@@ -8,11 +8,28 @@ require_relative 'dlq_publisher'
|
|
|
8
8
|
require_relative 'middleware'
|
|
9
9
|
|
|
10
10
|
module JetstreamBridge
|
|
11
|
-
# Immutable per-message metadata.
|
|
11
|
+
# Immutable per-message metadata extracted from a NATS message.
|
|
12
|
+
#
|
|
13
|
+
# @!attribute [r] event_id
|
|
14
|
+
# @return [String] Event identifier (from nats-msg-id header or generated UUID)
|
|
15
|
+
# @!attribute [r] deliveries
|
|
16
|
+
# @return [Integer] Number of delivery attempts
|
|
17
|
+
# @!attribute [r] subject
|
|
18
|
+
# @return [String] NATS subject the message arrived on
|
|
19
|
+
# @!attribute [r] seq
|
|
20
|
+
# @return [Integer, nil] Stream sequence number
|
|
21
|
+
# @!attribute [r] consumer
|
|
22
|
+
# @return [String, nil] Consumer name
|
|
23
|
+
# @!attribute [r] stream
|
|
24
|
+
# @return [String, nil] Stream name
|
|
12
25
|
MessageContext = Struct.new(
|
|
13
26
|
:event_id, :deliveries, :subject, :seq, :consumer, :stream,
|
|
14
27
|
keyword_init: true
|
|
15
28
|
) do
|
|
29
|
+
# Build a MessageContext from a raw NATS message.
|
|
30
|
+
#
|
|
31
|
+
# @param msg [NATS::Msg] Raw NATS message
|
|
32
|
+
# @return [MessageContext]
|
|
16
33
|
def self.build(msg)
|
|
17
34
|
new(
|
|
18
35
|
event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
|
|
@@ -26,13 +43,21 @@ module JetstreamBridge
|
|
|
26
43
|
end
|
|
27
44
|
|
|
28
45
|
# Simple exponential backoff strategy for transient failures.
|
|
46
|
+
#
|
|
47
|
+
# Produces a bounded delay in seconds based on delivery count and error type.
|
|
48
|
+
# Transient errors (Timeout, IO) use a lower base for faster retries.
|
|
29
49
|
class BackoffStrategy
|
|
50
|
+
# Error types considered transient (faster backoff)
|
|
30
51
|
TRANSIENT_ERRORS = [Timeout::Error, IOError].freeze
|
|
31
52
|
MAX_EXPONENT = 6
|
|
32
53
|
MAX_DELAY = 60
|
|
33
54
|
MIN_DELAY = 1
|
|
34
55
|
|
|
35
|
-
#
|
|
56
|
+
# Calculate delay for the next retry attempt.
|
|
57
|
+
#
|
|
58
|
+
# @param deliveries [Integer] Current delivery attempt number
|
|
59
|
+
# @param error [Exception] The error that triggered the retry
|
|
60
|
+
# @return [Integer] Delay in seconds, clamped between MIN_DELAY and MAX_DELAY
|
|
36
61
|
def delay(deliveries, error)
|
|
37
62
|
base = transient?(error) ? 0.5 : 2.0
|
|
38
63
|
power = [deliveries - 1, MAX_EXPONENT].min
|
|
@@ -47,13 +72,35 @@ module JetstreamBridge
|
|
|
47
72
|
end
|
|
48
73
|
end
|
|
49
74
|
|
|
50
|
-
# Orchestrates parse
|
|
75
|
+
# Orchestrates the parse -> handler -> ack/nak -> DLQ pipeline for each message.
|
|
76
|
+
#
|
|
77
|
+
# Responsible for deserializing incoming NATS messages, running them through
|
|
78
|
+
# the middleware chain and handler, and deciding whether to ACK, NAK, or
|
|
79
|
+
# route to the dead letter queue.
|
|
51
80
|
class MessageProcessor
|
|
81
|
+
# Error types that skip retry and go straight to DLQ
|
|
52
82
|
UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
|
|
83
|
+
|
|
84
|
+
# Result of processing a single message.
|
|
85
|
+
#
|
|
86
|
+
# @!attribute [r] action
|
|
87
|
+
# @return [Symbol] :ack or :nak
|
|
88
|
+
# @!attribute [r] ctx
|
|
89
|
+
# @return [MessageContext] Per-message metadata
|
|
90
|
+
# @!attribute [r] error
|
|
91
|
+
# @return [Exception, nil] Error if processing failed
|
|
92
|
+
# @!attribute [r] delay
|
|
93
|
+
# @return [Integer, nil] NAK delay in seconds
|
|
53
94
|
ActionResult = Struct.new(:action, :ctx, :error, :delay, keyword_init: true)
|
|
54
95
|
|
|
96
|
+
# @return [ConsumerMiddleware::MiddlewareChain] Middleware chain
|
|
55
97
|
attr_reader :middleware_chain
|
|
56
98
|
|
|
99
|
+
# @param jts [NATS::JetStream] JetStream context
|
|
100
|
+
# @param handler [#call] User-provided event handler
|
|
101
|
+
# @param dlq [DlqPublisher, nil] DLQ publisher (auto-created if nil)
|
|
102
|
+
# @param backoff [BackoffStrategy, nil] Backoff strategy (auto-created if nil)
|
|
103
|
+
# @param middleware_chain [ConsumerMiddleware::MiddlewareChain, nil] Middleware chain
|
|
57
104
|
def initialize(jts, handler, dlq: nil, backoff: nil, middleware_chain: nil)
|
|
58
105
|
@jts = jts
|
|
59
106
|
@handler = handler
|
|
@@ -62,6 +109,11 @@ module JetstreamBridge
|
|
|
62
109
|
@middleware_chain = middleware_chain || ConsumerMiddleware::MiddlewareChain.new
|
|
63
110
|
end
|
|
64
111
|
|
|
112
|
+
# Process a single NATS message through the full pipeline.
|
|
113
|
+
#
|
|
114
|
+
# @param msg [NATS::Msg] Raw NATS message
|
|
115
|
+
# @param auto_ack [Boolean] Whether to automatically ACK/NAK the message
|
|
116
|
+
# @return [ActionResult] Result indicating the action taken
|
|
65
117
|
def handle_message(msg, auto_ack: true)
|
|
66
118
|
ctx = MessageContext.build(msg)
|
|
67
119
|
event, early_action = parse_message(msg, ctx)
|
|
@@ -7,8 +7,14 @@ require_relative '../errors'
|
|
|
7
7
|
require_relative 'pull_subscription_builder'
|
|
8
8
|
|
|
9
9
|
module JetstreamBridge
|
|
10
|
-
#
|
|
10
|
+
# Manages durable consumer provisioning and subscription lifecycle.
|
|
11
|
+
#
|
|
12
|
+
# Encapsulates the ensure-consumer + subscribe flow for both pull and push
|
|
13
|
+
# consumer modes, with automatic fallback between modes.
|
|
11
14
|
class SubscriptionManager
|
|
15
|
+
# @param jts [NATS::JetStream] JetStream context
|
|
16
|
+
# @param durable [String] Durable consumer name
|
|
17
|
+
# @param cfg [Config] Configuration instance
|
|
12
18
|
def initialize(jts, durable, cfg = JetstreamBridge.config)
|
|
13
19
|
@jts = jts
|
|
14
20
|
@durable = durable
|
|
@@ -28,7 +34,14 @@ module JetstreamBridge
|
|
|
28
34
|
@desired_cfg
|
|
29
35
|
end
|
|
30
36
|
|
|
31
|
-
# Ensure consumer exists
|
|
37
|
+
# Ensure the durable consumer exists on the server.
|
|
38
|
+
#
|
|
39
|
+
# Skips all JetStream API calls when +auto_provision+ is false, assuming
|
|
40
|
+
# the consumer was pre-provisioned externally.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @raise [StreamNotFoundError] If the stream does not exist
|
|
44
|
+
# @raise [ConsumerProvisioningError] If consumer creation fails due to permissions
|
|
32
45
|
def ensure_consumer!(**_options)
|
|
33
46
|
unless @cfg.auto_provision
|
|
34
47
|
Logging.info("Skipping consumer validation (auto_provision=false); assuming '#{@durable}' exists.",
|
|
@@ -126,6 +139,12 @@ module JetstreamBridge
|
|
|
126
139
|
end
|
|
127
140
|
|
|
128
141
|
# Bind a subscriber to the existing durable consumer.
|
|
142
|
+
#
|
|
143
|
+
# Automatically selects pull or push mode based on configuration, with
|
|
144
|
+
# fallback to the opposite mode if the primary subscription fails.
|
|
145
|
+
#
|
|
146
|
+
# @return [Object] NATS subscription handle (pull or push)
|
|
147
|
+
# @raise [ConnectionError] If neither subscription mode succeeds
|
|
129
148
|
def subscribe!
|
|
130
149
|
if @cfg.push_consumer?
|
|
131
150
|
subscribe_push_with_fallback
|
|
@@ -49,10 +49,10 @@ module JetstreamBridge
|
|
|
49
49
|
PROCESSED = 'processed'
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
#
|
|
52
|
+
# Destination application name for subject routing
|
|
53
53
|
# @return [String]
|
|
54
54
|
attr_accessor :destination_app
|
|
55
|
-
# NATS server URL(s)
|
|
55
|
+
# NATS server URL(s), comma-separated for multiple servers
|
|
56
56
|
# @return [String]
|
|
57
57
|
attr_accessor :nats_urls
|
|
58
58
|
# JetStream stream name (required)
|
|
@@ -119,6 +119,12 @@ module JetstreamBridge
|
|
|
119
119
|
# @return [String, nil]
|
|
120
120
|
attr_accessor :push_consumer_group
|
|
121
121
|
|
|
122
|
+
# Initialize a new Config with sensible defaults.
|
|
123
|
+
#
|
|
124
|
+
# Reads initial values from environment variables when available
|
|
125
|
+
# (NATS_URLS, NATS_URL, JETSTREAM_STREAM_NAME, APP_NAME, DESTINATION_APP).
|
|
126
|
+
#
|
|
127
|
+
# @return [Config]
|
|
122
128
|
def initialize
|
|
123
129
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
124
130
|
@stream_name = ENV['JETSTREAM_STREAM_NAME'] || 'jetstream-bridge-stream'
|
|
@@ -60,22 +60,33 @@ module JetstreamBridge
|
|
|
60
60
|
#
|
|
61
61
|
# Safe to call from multiple threads - uses class-level mutex for synchronization.
|
|
62
62
|
#
|
|
63
|
-
# @return [NATS::JetStream
|
|
63
|
+
# @return [NATS::JetStream] JetStream context
|
|
64
64
|
def connect!(verify_js: nil)
|
|
65
65
|
@@connection_lock.synchronize { instance.connect!(verify_js: verify_js) }
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
#
|
|
68
|
+
# Returns the raw NATS client from the singleton instance.
|
|
69
|
+
#
|
|
70
|
+
# @return [NATS::IO::Client, nil] The underlying NATS connection
|
|
69
71
|
def nc
|
|
70
72
|
instance.__send__(:nc)
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
# Returns the JetStream context from the singleton instance.
|
|
76
|
+
#
|
|
77
|
+
# @return [NATS::JetStream, nil] JetStream context
|
|
78
|
+
# @raise [ConnectionNotEstablishedError] If JetStream context is unavailable
|
|
73
79
|
def jetstream
|
|
74
80
|
instance.__send__(:jetstream)
|
|
75
81
|
end
|
|
76
82
|
end
|
|
77
83
|
|
|
78
84
|
# Idempotent: returns an existing, healthy JetStream context or establishes one.
|
|
85
|
+
#
|
|
86
|
+
# @param verify_js [Boolean, nil] Whether to verify JetStream availability via account_info.
|
|
87
|
+
# Defaults to the value of {Config#auto_provision}.
|
|
88
|
+
# @return [NATS::JetStream] JetStream context
|
|
89
|
+
# @raise [ConnectionError] If unable to connect to NATS or JetStream
|
|
79
90
|
def connect!(verify_js: nil)
|
|
80
91
|
verify_js = config_auto_provision if verify_js.nil?
|
|
81
92
|
# Check if already connected without acquiring mutex (for performance)
|
|
@@ -9,17 +9,20 @@ module JetstreamBridge
|
|
|
9
9
|
# seconds, >=1000 as milliseconds. Strings with unit suffixes are supported
|
|
10
10
|
# (e.g., "30s", "500ms", "1h").
|
|
11
11
|
#
|
|
12
|
-
#
|
|
12
|
+
# @example Integer with auto-detection
|
|
13
13
|
# Duration.to_millis(2) #=> 2000 (auto: seconds)
|
|
14
14
|
# Duration.to_millis(1500) #=> 1500 (auto: milliseconds)
|
|
15
|
+
#
|
|
16
|
+
# @example Explicit units
|
|
15
17
|
# Duration.to_millis(30, default_unit: :s) #=> 30000
|
|
18
|
+
# Duration.to_millis(1_500_000_000, default_unit: :ns) #=> 1500
|
|
19
|
+
#
|
|
20
|
+
# @example String with suffix
|
|
16
21
|
# Duration.to_millis("30s") #=> 30000
|
|
17
22
|
# Duration.to_millis("500ms") #=> 500
|
|
18
|
-
# Duration.to_millis("250us") #=> 0
|
|
19
23
|
# Duration.to_millis("1h") #=> 3_600_000
|
|
20
|
-
# Duration.to_millis(1_500_000_000, default_unit: :ns) #=> 1500
|
|
21
24
|
#
|
|
22
|
-
#
|
|
25
|
+
# @example Normalizing a list
|
|
23
26
|
# Duration.normalize_list_to_millis(%w[1s 5s 15s]) #=> [1000, 5000, 15000]
|
|
24
27
|
module Duration
|
|
25
28
|
# multipliers to convert 1 unit into milliseconds
|
|
@@ -39,10 +42,14 @@ module JetstreamBridge
|
|
|
39
42
|
|
|
40
43
|
module_function
|
|
41
44
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
45
|
+
# Convert a duration value to milliseconds.
|
|
46
|
+
#
|
|
47
|
+
# @param val [Integer, Float, String] Duration value to convert
|
|
48
|
+
# @param default_unit [Symbol] Unit for bare numbers.
|
|
49
|
+
# :auto (heuristic: <1000 => seconds, >=1000 => ms),
|
|
50
|
+
# :ms, :ns, :us, :s, :m, :h, :d
|
|
51
|
+
# @return [Integer] Duration in milliseconds
|
|
52
|
+
# @raise [ArgumentError] If the value cannot be parsed
|
|
46
53
|
def to_millis(val, default_unit: :auto)
|
|
47
54
|
case val
|
|
48
55
|
when Integer then int_to_ms(val, default_unit: default_unit)
|
|
@@ -57,6 +64,10 @@ module JetstreamBridge
|
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
# Normalize an array of durations into integer milliseconds.
|
|
67
|
+
#
|
|
68
|
+
# @param values [Array<Integer, Float, String>] Duration values
|
|
69
|
+
# @param default_unit [Symbol] Default unit for bare numbers
|
|
70
|
+
# @return [Array<Integer>] Durations in milliseconds
|
|
60
71
|
def normalize_list_to_millis(values, default_unit: :auto)
|
|
61
72
|
vals = Array(values)
|
|
62
73
|
return [] if vals.empty?
|
|
@@ -69,6 +80,10 @@ module JetstreamBridge
|
|
|
69
80
|
# Retains the nanosecond heuristic used in SubscriptionManager:
|
|
70
81
|
# extremely large integers (>= 1_000_000_000) are treated as nanoseconds
|
|
71
82
|
# when default_unit is :auto.
|
|
83
|
+
#
|
|
84
|
+
# @param val [Integer, Float, String, nil] Duration value (returns nil if nil)
|
|
85
|
+
# @param default_unit [Symbol] Default unit for bare numbers
|
|
86
|
+
# @return [Integer, nil] Duration in seconds (minimum 1), or nil if val is nil
|
|
72
87
|
def to_seconds(val, default_unit: :auto)
|
|
73
88
|
return nil if val.nil?
|
|
74
89
|
|
|
@@ -82,6 +97,10 @@ module JetstreamBridge
|
|
|
82
97
|
end
|
|
83
98
|
|
|
84
99
|
# Normalize an array of durations into integer seconds.
|
|
100
|
+
#
|
|
101
|
+
# @param values [Array<Integer, Float, String>] Duration values
|
|
102
|
+
# @param default_unit [Symbol] Default unit for bare numbers
|
|
103
|
+
# @return [Array<Integer>] Durations in seconds
|
|
85
104
|
def normalize_list_to_seconds(values, default_unit: :auto)
|
|
86
105
|
vals = Array(values)
|
|
87
106
|
return [] if vals.empty?
|
|
@@ -9,33 +9,59 @@ module JetstreamBridge
|
|
|
9
9
|
module Logging
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
|
+
# Returns the active logger instance.
|
|
13
|
+
#
|
|
14
|
+
# Resolution order: configured logger, Rails.logger, STDOUT fallback.
|
|
15
|
+
#
|
|
16
|
+
# @return [Logger] Active logger
|
|
12
17
|
def logger
|
|
13
18
|
JetstreamBridge.config.logger ||
|
|
14
19
|
(defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
|
|
15
20
|
default_logger
|
|
16
21
|
end
|
|
17
22
|
|
|
23
|
+
# Returns a default STDOUT logger, memoized for reuse.
|
|
24
|
+
#
|
|
25
|
+
# @return [Logger]
|
|
18
26
|
def default_logger
|
|
19
27
|
@default_logger ||= Logger.new($stdout)
|
|
20
28
|
end
|
|
21
29
|
|
|
30
|
+
# Log a message at the given level with an optional tag prefix.
|
|
31
|
+
#
|
|
32
|
+
# @param level [Symbol] Log level (:debug, :info, :warn, :error)
|
|
33
|
+
# @param msg [String] Message to log
|
|
34
|
+
# @param tag [String, nil] Optional prefix (e.g. "JetstreamBridge::Consumer")
|
|
35
|
+
# @return [void]
|
|
22
36
|
def log(level, msg, tag: nil)
|
|
23
37
|
message = tag ? "[#{tag}] #{msg}" : msg
|
|
24
38
|
logger.public_send(level, message)
|
|
25
39
|
end
|
|
26
40
|
|
|
41
|
+
# @param msg [String] Message to log
|
|
42
|
+
# @param tag [String, nil] Optional prefix
|
|
43
|
+
# @return [void]
|
|
27
44
|
def debug(msg, tag: nil)
|
|
28
45
|
log(:debug, msg, tag: tag)
|
|
29
46
|
end
|
|
30
47
|
|
|
48
|
+
# @param msg [String] Message to log
|
|
49
|
+
# @param tag [String, nil] Optional prefix
|
|
50
|
+
# @return [void]
|
|
31
51
|
def info(msg, tag: nil)
|
|
32
52
|
log(:info, msg, tag: tag)
|
|
33
53
|
end
|
|
34
54
|
|
|
55
|
+
# @param msg [String] Message to log
|
|
56
|
+
# @param tag [String, nil] Optional prefix
|
|
57
|
+
# @return [void]
|
|
35
58
|
def warn(msg, tag: nil)
|
|
36
59
|
log(:warn, msg, tag: tag)
|
|
37
60
|
end
|
|
38
61
|
|
|
62
|
+
# @param msg [String] Message to log
|
|
63
|
+
# @param tag [String, nil] Optional prefix
|
|
64
|
+
# @return [void]
|
|
39
65
|
def error(msg, tag: nil)
|
|
40
66
|
log(:error, msg, tag: tag)
|
|
41
67
|
end
|
|
@@ -1,30 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module JetstreamBridge
|
|
4
|
-
# Base error for all JetStream Bridge errors
|
|
4
|
+
# Base error for all JetStream Bridge errors.
|
|
5
|
+
#
|
|
6
|
+
# Every error carries an optional +context+ hash for structured diagnostics.
|
|
5
7
|
class Error < StandardError
|
|
8
|
+
# @return [Hash] Structured context for diagnostics
|
|
6
9
|
attr_reader :context
|
|
7
10
|
|
|
11
|
+
# @param message [String, nil] Human-readable error message
|
|
12
|
+
# @param context [Hash] Arbitrary diagnostic context (frozen on assignment)
|
|
8
13
|
def initialize(message = nil, context: {})
|
|
9
14
|
super(message)
|
|
10
15
|
@context = context.freeze
|
|
11
16
|
end
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
#
|
|
19
|
+
# Raised when configuration values are invalid or inconsistent.
|
|
15
20
|
class ConfigurationError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a NATS subject contains invalid characters.
|
|
16
23
|
class InvalidSubjectError < ConfigurationError; end
|
|
24
|
+
|
|
25
|
+
# Raised when a required configuration value is missing.
|
|
17
26
|
class MissingConfigurationError < ConfigurationError; end
|
|
18
27
|
|
|
19
|
-
#
|
|
28
|
+
# Raised when a NATS connection cannot be established or is lost.
|
|
20
29
|
class ConnectionError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when an operation requires a connection that has not been established.
|
|
21
32
|
class ConnectionNotEstablishedError < ConnectionError; end
|
|
33
|
+
|
|
34
|
+
# Raised when a health check fails or is rate-limited.
|
|
22
35
|
class HealthCheckFailedError < ConnectionError; end
|
|
23
36
|
|
|
24
|
-
#
|
|
37
|
+
# Raised when an event fails to publish.
|
|
25
38
|
class PublishError < Error
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
# @return [String, nil] Event ID that failed to publish
|
|
40
|
+
attr_reader :event_id
|
|
41
|
+
# @return [String, nil] NATS subject the publish was attempted on
|
|
42
|
+
attr_reader :subject
|
|
43
|
+
|
|
44
|
+
# @param message [String, nil] Human-readable error message
|
|
45
|
+
# @param event_id [String, nil] The event ID
|
|
46
|
+
# @param subject [String, nil] The NATS subject
|
|
47
|
+
# @param context [Hash] Additional diagnostic context
|
|
28
48
|
def initialize(message = nil, event_id: nil, subject: nil, context: {})
|
|
29
49
|
@event_id = event_id
|
|
30
50
|
@subject = subject
|
|
@@ -32,12 +52,23 @@ module JetstreamBridge
|
|
|
32
52
|
end
|
|
33
53
|
end
|
|
34
54
|
|
|
55
|
+
# Raised when a publish operation fails after retries.
|
|
35
56
|
class PublishFailedError < PublishError; end
|
|
57
|
+
|
|
58
|
+
# Raised when the outbox persistence layer encounters an error.
|
|
36
59
|
class OutboxError < PublishError; end
|
|
37
60
|
|
|
61
|
+
# Raised when a batch publish has one or more failures.
|
|
38
62
|
class BatchPublishError < PublishError
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
# @return [Array<Hash>] Details of each failed event
|
|
64
|
+
attr_reader :failed_events
|
|
65
|
+
# @return [Integer] Number of events that published successfully
|
|
66
|
+
attr_reader :successful_count
|
|
67
|
+
|
|
68
|
+
# @param message [String, nil] Human-readable error message
|
|
69
|
+
# @param failed_events [Array<Hash>] Failed event details
|
|
70
|
+
# @param successful_count [Integer] Count of successful publishes
|
|
71
|
+
# @param context [Hash] Additional diagnostic context
|
|
41
72
|
def initialize(message = nil, failed_events: [], successful_count: 0, context: {})
|
|
42
73
|
@failed_events = failed_events
|
|
43
74
|
@successful_count = successful_count
|
|
@@ -51,10 +82,17 @@ module JetstreamBridge
|
|
|
51
82
|
end
|
|
52
83
|
end
|
|
53
84
|
|
|
54
|
-
#
|
|
85
|
+
# Raised when message consumption encounters an error.
|
|
55
86
|
class ConsumerError < Error
|
|
56
|
-
|
|
57
|
-
|
|
87
|
+
# @return [String, nil] Event ID being processed
|
|
88
|
+
attr_reader :event_id
|
|
89
|
+
# @return [Integer, nil] Number of delivery attempts so far
|
|
90
|
+
attr_reader :deliveries
|
|
91
|
+
|
|
92
|
+
# @param message [String, nil] Human-readable error message
|
|
93
|
+
# @param event_id [String, nil] The event ID being consumed
|
|
94
|
+
# @param deliveries [Integer, nil] Delivery attempt count
|
|
95
|
+
# @param context [Hash] Additional diagnostic context
|
|
58
96
|
def initialize(message = nil, event_id: nil, deliveries: nil, context: {})
|
|
59
97
|
@event_id = event_id
|
|
60
98
|
@deliveries = deliveries
|
|
@@ -62,24 +100,44 @@ module JetstreamBridge
|
|
|
62
100
|
end
|
|
63
101
|
end
|
|
64
102
|
|
|
103
|
+
# Raised when a user-provided handler raises during event processing.
|
|
65
104
|
class HandlerError < ConsumerError; end
|
|
105
|
+
|
|
106
|
+
# Raised when the inbox deduplication layer encounters an error.
|
|
66
107
|
class InboxError < ConsumerError; end
|
|
67
108
|
|
|
68
|
-
#
|
|
109
|
+
# Raised when stream or consumer topology operations fail.
|
|
69
110
|
class TopologyError < Error; end
|
|
111
|
+
|
|
112
|
+
# Raised when a JetStream consumer cannot be provisioned.
|
|
70
113
|
class ConsumerProvisioningError < TopologyError; end
|
|
114
|
+
|
|
115
|
+
# Raised when the configured stream does not exist.
|
|
71
116
|
class StreamNotFoundError < TopologyError; end
|
|
117
|
+
|
|
118
|
+
# Raised when subject filter patterns overlap between consumers.
|
|
72
119
|
class SubjectOverlapError < TopologyError; end
|
|
120
|
+
|
|
121
|
+
# Raised when stream creation fails.
|
|
73
122
|
class StreamCreationFailedError < TopologyError; end
|
|
74
123
|
|
|
75
|
-
# DLQ
|
|
124
|
+
# Raised when a DLQ operation fails.
|
|
76
125
|
class DlqError < Error; end
|
|
126
|
+
|
|
127
|
+
# Raised when publishing a message to the dead letter queue fails.
|
|
77
128
|
class DlqPublishFailedError < DlqError; end
|
|
78
129
|
|
|
79
|
-
#
|
|
130
|
+
# Raised when all retry attempts have been exhausted.
|
|
80
131
|
class RetryExhausted < Error
|
|
81
|
-
|
|
82
|
-
|
|
132
|
+
# @return [Integer] Total number of attempts made
|
|
133
|
+
attr_reader :attempts
|
|
134
|
+
# @return [Exception, nil] The last error that triggered the retry
|
|
135
|
+
attr_reader :original_error
|
|
136
|
+
|
|
137
|
+
# @param message [String, nil] Human-readable error message (auto-generated if nil)
|
|
138
|
+
# @param attempts [Integer] Number of attempts made
|
|
139
|
+
# @param original_error [Exception, nil] The underlying error
|
|
140
|
+
# @param context [Hash] Additional diagnostic context
|
|
83
141
|
def initialize(message = nil, attempts: 0, original_error: nil, context: {})
|
|
84
142
|
@attempts = attempts
|
|
85
143
|
@original_error = original_error
|
|
@@ -15,7 +15,23 @@ module JetstreamBridge
|
|
|
15
15
|
# puts event.metadata.trace_id # "abc123"
|
|
16
16
|
# end
|
|
17
17
|
class Event
|
|
18
|
-
# Metadata associated with message delivery
|
|
18
|
+
# Metadata associated with message delivery.
|
|
19
|
+
#
|
|
20
|
+
# Contains NATS-level delivery information such as the subject,
|
|
21
|
+
# delivery count, stream name, and sequence number.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] subject
|
|
24
|
+
# @return [String] NATS subject the message was received on
|
|
25
|
+
# @!attribute [r] deliveries
|
|
26
|
+
# @return [Integer] Number of delivery attempts
|
|
27
|
+
# @!attribute [r] stream
|
|
28
|
+
# @return [String, nil] Stream name
|
|
29
|
+
# @!attribute [r] sequence
|
|
30
|
+
# @return [Integer, nil] Message sequence number in the stream
|
|
31
|
+
# @!attribute [r] consumer
|
|
32
|
+
# @return [String, nil] Consumer name
|
|
33
|
+
# @!attribute [r] timestamp
|
|
34
|
+
# @return [Time] When the metadata was captured
|
|
19
35
|
Metadata = Struct.new(
|
|
20
36
|
:subject,
|
|
21
37
|
:deliveries,
|
|
@@ -37,8 +53,15 @@ module JetstreamBridge
|
|
|
37
53
|
end
|
|
38
54
|
end
|
|
39
55
|
|
|
40
|
-
#
|
|
56
|
+
# Wraps a Hash payload to allow method-style access to its keys.
|
|
57
|
+
#
|
|
58
|
+
# @example
|
|
59
|
+
# accessor = PayloadAccessor.new("user_id" => 42)
|
|
60
|
+
# accessor.user_id #=> 42
|
|
61
|
+
# accessor["user_id"] #=> 42
|
|
62
|
+
# accessor.to_h #=> {"user_id" => 42}
|
|
41
63
|
class PayloadAccessor
|
|
64
|
+
# @param payload [Hash] Raw payload hash
|
|
42
65
|
def initialize(payload)
|
|
43
66
|
@payload = payload.is_a?(Hash) ? payload.transform_keys(&:to_s) : {}
|
|
44
67
|
end
|
|
@@ -68,6 +91,15 @@ module JetstreamBridge
|
|
|
68
91
|
alias to_hash to_h
|
|
69
92
|
end
|
|
70
93
|
|
|
94
|
+
# @return [String] Unique event identifier
|
|
95
|
+
# @return [String] Event type (e.g. "user.created")
|
|
96
|
+
# @return [String] Resource type (e.g. "user")
|
|
97
|
+
# @return [String] Resource identifier
|
|
98
|
+
# @return [String] Name of the producing application
|
|
99
|
+
# @return [Time, nil] When the event occurred
|
|
100
|
+
# @return [String] Distributed trace identifier
|
|
101
|
+
# @return [Integer] Envelope schema version
|
|
102
|
+
# @return [Metadata] Message delivery metadata
|
|
71
103
|
attr_reader :event_id, :type, :resource_type, :resource_id,
|
|
72
104
|
:producer, :occurred_at, :trace_id, :schema_version,
|
|
73
105
|
:metadata
|
|
@@ -5,13 +5,37 @@ require 'time'
|
|
|
5
5
|
|
|
6
6
|
module JetstreamBridge
|
|
7
7
|
module Models
|
|
8
|
-
#
|
|
8
|
+
# Immutable value object representing an event envelope.
|
|
9
|
+
#
|
|
10
|
+
# Encapsulates all fields of a JetStream Bridge event and freezes itself
|
|
11
|
+
# (including the payload) after construction for thread-safety.
|
|
9
12
|
class EventEnvelope
|
|
13
|
+
# Current envelope schema version
|
|
10
14
|
SCHEMA_VERSION = 1
|
|
11
15
|
|
|
16
|
+
# @return [String] Unique event identifier (UUID)
|
|
17
|
+
# @return [Integer] Envelope schema version
|
|
18
|
+
# @return [String] Event type (e.g. "created", "updated")
|
|
19
|
+
# @return [String] Name of the producing application
|
|
20
|
+
# @return [String] Resource type (e.g. "user", "order")
|
|
21
|
+
# @return [String] Resource identifier extracted from payload
|
|
22
|
+
# @return [Time] When the event occurred
|
|
23
|
+
# @return [String] Distributed trace identifier
|
|
24
|
+
# @return [Hash] Frozen event payload
|
|
12
25
|
attr_reader :event_id, :schema_version, :event_type, :producer,
|
|
13
26
|
:resource_type, :resource_id, :occurred_at, :trace_id, :payload
|
|
14
27
|
|
|
28
|
+
# Build a new EventEnvelope.
|
|
29
|
+
#
|
|
30
|
+
# @param resource_type [String] Resource type (e.g. "user")
|
|
31
|
+
# @param event_type [String] Event type (e.g. "created")
|
|
32
|
+
# @param payload [Hash] Event payload data
|
|
33
|
+
# @param event_id [String, nil] Custom event ID (auto-generated UUID if nil)
|
|
34
|
+
# @param occurred_at [Time, String, nil] Event timestamp (defaults to now)
|
|
35
|
+
# @param trace_id [String, nil] Distributed trace ID (auto-generated if nil)
|
|
36
|
+
# @param producer [String, nil] Producer app name (defaults to config.app_name)
|
|
37
|
+
# @param resource_id [String, nil] Resource ID (extracted from payload if nil)
|
|
38
|
+
# @raise [ArgumentError] If required fields are blank
|
|
15
39
|
def initialize(
|
|
16
40
|
resource_type:,
|
|
17
41
|
event_type:,
|
|
@@ -36,7 +60,9 @@ module JetstreamBridge
|
|
|
36
60
|
freeze
|
|
37
61
|
end
|
|
38
62
|
|
|
39
|
-
# Convert to hash for serialization
|
|
63
|
+
# Convert to hash for serialization.
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash] Envelope fields as a symbol-keyed hash
|
|
40
66
|
def to_h
|
|
41
67
|
hash = {
|
|
42
68
|
event_id: @event_id,
|
|
@@ -55,7 +81,10 @@ module JetstreamBridge
|
|
|
55
81
|
hash
|
|
56
82
|
end
|
|
57
83
|
|
|
58
|
-
#
|
|
84
|
+
# Reconstruct an EventEnvelope from a hash (e.g. deserialized JSON).
|
|
85
|
+
#
|
|
86
|
+
# @param hash [Hash] String- or symbol-keyed envelope data
|
|
87
|
+
# @return [EventEnvelope]
|
|
59
88
|
def self.from_h(hash)
|
|
60
89
|
new(
|
|
61
90
|
event_id: hash['event_id'] || hash[:event_id],
|
|
@@ -96,6 +96,8 @@ module JetstreamBridge
|
|
|
96
96
|
alias to_hash to_h
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# @param publisher [Publisher, nil] Publisher instance to use for each event.
|
|
100
|
+
# Defaults to a new {Publisher} instance.
|
|
99
101
|
def initialize(publisher = nil)
|
|
100
102
|
@publisher = publisher || Publisher.new
|
|
101
103
|
@events = []
|
|
@@ -156,6 +158,9 @@ module JetstreamBridge
|
|
|
156
158
|
alias count size
|
|
157
159
|
alias length size
|
|
158
160
|
|
|
161
|
+
# Whether the batch has no queued events.
|
|
162
|
+
#
|
|
163
|
+
# @return [Boolean]
|
|
159
164
|
def empty?
|
|
160
165
|
@events.empty?
|
|
161
166
|
end
|
|
@@ -76,11 +76,11 @@ module JetstreamBridge
|
|
|
76
76
|
# - occurred_at [Time, String] Event timestamp (defaults to current time)
|
|
77
77
|
#
|
|
78
78
|
# @return [Models::PublishResult] Result object containing:
|
|
79
|
-
# - success [Boolean] Whether publish succeeded
|
|
79
|
+
# - success? [Boolean] Whether publish succeeded
|
|
80
80
|
# - event_id [String] The published event ID
|
|
81
81
|
# - subject [String] NATS subject used
|
|
82
82
|
# - error [Exception, nil] Error if publish failed
|
|
83
|
-
# - duplicate [Boolean] Whether NATS detected as duplicate
|
|
83
|
+
# - duplicate? [Boolean] Whether NATS detected as duplicate
|
|
84
84
|
#
|
|
85
85
|
# @raise [ArgumentError] If required parameters are missing or invalid
|
|
86
86
|
#
|
data/lib/jetstream_bridge.rb
CHANGED
|
@@ -68,6 +68,9 @@ module JetstreamBridge
|
|
|
68
68
|
class << self
|
|
69
69
|
include Core::BridgeHelpers
|
|
70
70
|
|
|
71
|
+
# Returns the current configuration, creating a default instance if needed.
|
|
72
|
+
#
|
|
73
|
+
# @return [Config] The current configuration object
|
|
71
74
|
def config
|
|
72
75
|
@config ||= Config.new
|
|
73
76
|
end
|
|
@@ -90,6 +93,7 @@ module JetstreamBridge
|
|
|
90
93
|
# JetstreamBridge.configure(app_name: 'my_app')
|
|
91
94
|
#
|
|
92
95
|
# @param overrides [Hash] Configuration key-value pairs to set
|
|
96
|
+
# @param extra_overrides [Hash] Additional keyword arguments merged into overrides
|
|
93
97
|
# @yield [Config] Configuration object for block-based configuration
|
|
94
98
|
# @return [Config] The configured instance
|
|
95
99
|
def configure(overrides = {}, **extra_overrides)
|
|
@@ -126,6 +130,12 @@ module JetstreamBridge
|
|
|
126
130
|
end
|
|
127
131
|
end
|
|
128
132
|
|
|
133
|
+
# Reset all configuration and connection state.
|
|
134
|
+
#
|
|
135
|
+
# Clears the config singleton and marks the connection as uninitialized.
|
|
136
|
+
# Primarily used in tests to restore a clean slate between examples.
|
|
137
|
+
#
|
|
138
|
+
# @return [void]
|
|
129
139
|
def reset!
|
|
130
140
|
@config = nil
|
|
131
141
|
@connection_initialized = false
|
|
@@ -137,6 +147,8 @@ module JetstreamBridge
|
|
|
137
147
|
# This method can be called explicitly if needed. It's idempotent and safe to call multiple times.
|
|
138
148
|
#
|
|
139
149
|
# @return [void]
|
|
150
|
+
# @raise [ConfigurationError] If configuration validation fails
|
|
151
|
+
# @raise [ConnectionError] If unable to connect to NATS
|
|
140
152
|
def startup!
|
|
141
153
|
return if @connection_initialized
|
|
142
154
|
|
|
@@ -186,14 +198,23 @@ module JetstreamBridge
|
|
|
186
198
|
end
|
|
187
199
|
end
|
|
188
200
|
|
|
201
|
+
# Whether the transactional outbox pattern is enabled.
|
|
202
|
+
#
|
|
203
|
+
# @return [Boolean]
|
|
189
204
|
def use_outbox?
|
|
190
205
|
config.use_outbox
|
|
191
206
|
end
|
|
192
207
|
|
|
208
|
+
# Whether the idempotent inbox pattern is enabled.
|
|
209
|
+
#
|
|
210
|
+
# @return [Boolean]
|
|
193
211
|
def use_inbox?
|
|
194
212
|
config.use_inbox
|
|
195
213
|
end
|
|
196
214
|
|
|
215
|
+
# Whether the dead letter queue is enabled.
|
|
216
|
+
#
|
|
217
|
+
# @return [Boolean]
|
|
197
218
|
def use_dlq?
|
|
198
219
|
config.use_dlq
|
|
199
220
|
end
|
|
@@ -311,7 +332,8 @@ module JetstreamBridge
|
|
|
311
332
|
# @param event_type [String, nil] Event type (e.g., 'created', 'updated', 'user.created')
|
|
312
333
|
# @param payload [Hash, nil] Event payload data
|
|
313
334
|
# @param subject [String, nil] Optional subject override
|
|
314
|
-
# @param
|
|
335
|
+
# @param kwargs [Hash] Additional keyword arguments forwarded to Publisher#publish
|
|
336
|
+
# (event_id, occurred_at, trace_id)
|
|
315
337
|
# @return [Models::PublishResult] Result object with success status and metadata
|
|
316
338
|
#
|
|
317
339
|
# @example Check result status
|