jetstream_bridge 3.0.1 → 4.0.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 +45 -1
- data/README.md +1147 -82
- data/lib/jetstream_bridge/consumer/consumer.rb +174 -6
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +4 -4
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/message_processor.rb +41 -7
- data/lib/jetstream_bridge/consumer/middleware.rb +154 -0
- data/lib/jetstream_bridge/core/config.rb +150 -9
- data/lib/jetstream_bridge/core/config_preset.rb +99 -0
- data/lib/jetstream_bridge/core/connection.rb +5 -2
- data/lib/jetstream_bridge/core/connection_factory.rb +1 -1
- data/lib/jetstream_bridge/core/duration.rb +21 -37
- data/lib/jetstream_bridge/errors.rb +60 -8
- data/lib/jetstream_bridge/models/event.rb +202 -0
- data/lib/jetstream_bridge/{inbox_event.rb → models/inbox_event.rb} +62 -4
- data/lib/jetstream_bridge/{outbox_event.rb → models/outbox_event.rb} +65 -16
- data/lib/jetstream_bridge/models/publish_result.rb +64 -0
- data/lib/jetstream_bridge/models/subject.rb +56 -4
- data/lib/jetstream_bridge/publisher/batch_publisher.rb +163 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +240 -21
- data/lib/jetstream_bridge/test_helpers.rb +275 -0
- data/lib/jetstream_bridge/topology/overlap_guard.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +178 -3
- data/lib/tasks/yard.rake +18 -0
- metadata +11 -4
|
@@ -12,34 +12,178 @@ require_relative 'subscription_manager'
|
|
|
12
12
|
require_relative 'inbox/inbox_processor'
|
|
13
13
|
|
|
14
14
|
module JetstreamBridge
|
|
15
|
-
# Subscribes to destination subject and processes messages via a pull durable.
|
|
15
|
+
# Subscribes to destination subject and processes messages via a pull durable consumer.
|
|
16
|
+
#
|
|
17
|
+
# The Consumer provides reliable message processing with features like:
|
|
18
|
+
# - Durable pull-based subscriptions with configurable batch sizes
|
|
19
|
+
# - Optional idempotent inbox pattern for exactly-once processing
|
|
20
|
+
# - Middleware support for cross-cutting concerns (logging, metrics, tracing)
|
|
21
|
+
# - Automatic reconnection and error recovery
|
|
22
|
+
# - Graceful shutdown with message draining
|
|
23
|
+
#
|
|
24
|
+
# @example Basic consumer
|
|
25
|
+
# consumer = JetstreamBridge::Consumer.new do |event|
|
|
26
|
+
# puts "Received: #{event.type} - #{event.payload.to_h}"
|
|
27
|
+
# # Process event...
|
|
28
|
+
# end
|
|
29
|
+
# consumer.run!
|
|
30
|
+
#
|
|
31
|
+
# @example Consumer with middleware
|
|
32
|
+
# consumer = JetstreamBridge::Consumer.new(handler)
|
|
33
|
+
# consumer.use(JetstreamBridge::Consumer::LoggingMiddleware.new)
|
|
34
|
+
# consumer.use(JetstreamBridge::Consumer::MetricsMiddleware.new)
|
|
35
|
+
# consumer.run!
|
|
36
|
+
#
|
|
37
|
+
# @example Using convenience method
|
|
38
|
+
# JetstreamBridge.subscribe do |event|
|
|
39
|
+
# ProcessEventJob.perform_later(event.to_h)
|
|
40
|
+
# end.run!
|
|
41
|
+
#
|
|
16
42
|
class Consumer
|
|
43
|
+
# Default number of messages to fetch in each batch
|
|
17
44
|
DEFAULT_BATCH_SIZE = 25
|
|
45
|
+
# Timeout for fetching messages from NATS (seconds)
|
|
18
46
|
FETCH_TIMEOUT_SECS = 5
|
|
47
|
+
# Initial sleep duration when no messages available (seconds)
|
|
19
48
|
IDLE_SLEEP_SECS = 0.05
|
|
49
|
+
# Maximum sleep duration during idle periods (seconds)
|
|
20
50
|
MAX_IDLE_BACKOFF_SECS = 1.0
|
|
21
51
|
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
# Alias middleware classes for easier access
|
|
53
|
+
MiddlewareChain = ConsumerMiddleware::MiddlewareChain
|
|
54
|
+
LoggingMiddleware = ConsumerMiddleware::LoggingMiddleware
|
|
55
|
+
ErrorHandlingMiddleware = ConsumerMiddleware::ErrorHandlingMiddleware
|
|
56
|
+
MetricsMiddleware = ConsumerMiddleware::MetricsMiddleware
|
|
57
|
+
TracingMiddleware = ConsumerMiddleware::TracingMiddleware
|
|
58
|
+
TimeoutMiddleware = ConsumerMiddleware::TimeoutMiddleware
|
|
59
|
+
|
|
60
|
+
# @return [String] Durable consumer name
|
|
61
|
+
attr_reader :durable
|
|
62
|
+
# @return [Integer] Batch size for message fetching
|
|
63
|
+
attr_reader :batch_size
|
|
64
|
+
# @return [MiddlewareChain] Middleware chain for processing
|
|
65
|
+
attr_reader :middleware_chain
|
|
66
|
+
|
|
67
|
+
# Initialize a new Consumer instance.
|
|
68
|
+
#
|
|
69
|
+
# @param handler [Proc, #call, nil] Message handler that processes events.
|
|
70
|
+
# Must respond to #call(event) or #call(event, subject, deliveries).
|
|
71
|
+
# @param durable_name [String, nil] Optional durable consumer name override.
|
|
72
|
+
# Defaults to config.durable_name.
|
|
73
|
+
# @param batch_size [Integer, nil] Number of messages to fetch per batch.
|
|
74
|
+
# Defaults to DEFAULT_BATCH_SIZE (25).
|
|
75
|
+
# @yield [event] Optional block as handler. Receives Models::Event object.
|
|
76
|
+
#
|
|
77
|
+
# @raise [ArgumentError] If neither handler nor block provided
|
|
78
|
+
# @raise [ArgumentError] If destination_app not configured
|
|
79
|
+
# @raise [ConnectionError] If unable to connect to NATS
|
|
80
|
+
#
|
|
81
|
+
# @example With proc handler
|
|
82
|
+
# handler = ->(event) { puts "Received: #{event.type}" }
|
|
83
|
+
# consumer = JetstreamBridge::Consumer.new(handler)
|
|
84
|
+
#
|
|
85
|
+
# @example With block
|
|
86
|
+
# consumer = JetstreamBridge::Consumer.new do |event|
|
|
87
|
+
# UserEventHandler.process(event)
|
|
88
|
+
# end
|
|
89
|
+
#
|
|
90
|
+
# @example With custom configuration
|
|
91
|
+
# consumer = JetstreamBridge::Consumer.new(
|
|
92
|
+
# handler,
|
|
93
|
+
# durable_name: "my-consumer",
|
|
94
|
+
# batch_size: 10
|
|
95
|
+
# )
|
|
96
|
+
#
|
|
97
|
+
def initialize(handler = nil, durable_name: nil, batch_size: nil, &block)
|
|
98
|
+
@handler = handler || block
|
|
99
|
+
raise ArgumentError, 'handler or block required' unless @handler
|
|
24
100
|
|
|
25
|
-
@handler = block
|
|
26
101
|
@batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
|
|
27
102
|
@durable = durable_name || JetstreamBridge.config.durable_name
|
|
28
103
|
@idle_backoff = IDLE_SLEEP_SECS
|
|
29
104
|
@running = true
|
|
30
105
|
@shutdown_requested = false
|
|
31
106
|
@jts = Connection.connect!
|
|
107
|
+
@middleware_chain = MiddlewareChain.new
|
|
32
108
|
|
|
33
109
|
ensure_destination!
|
|
34
110
|
|
|
35
111
|
@sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
|
|
36
|
-
@processor = MessageProcessor.new(@jts, @handler)
|
|
112
|
+
@processor = MessageProcessor.new(@jts, @handler, middleware_chain: @middleware_chain)
|
|
37
113
|
@inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
|
|
38
114
|
|
|
39
115
|
ensure_subscription!
|
|
40
116
|
setup_signal_handlers
|
|
41
117
|
end
|
|
42
118
|
|
|
119
|
+
# Add middleware to the processing chain.
|
|
120
|
+
#
|
|
121
|
+
# Middleware is executed in the order it's added. Each middleware must respond
|
|
122
|
+
# to #call(event, &block) and yield to continue the chain.
|
|
123
|
+
#
|
|
124
|
+
# @param middleware [Object] Middleware that responds to #call(event, &block).
|
|
125
|
+
# Must yield to continue processing.
|
|
126
|
+
# @return [self] Returns self for method chaining
|
|
127
|
+
#
|
|
128
|
+
# @example Adding multiple middleware
|
|
129
|
+
# consumer = JetstreamBridge.subscribe { |event| process(event) }
|
|
130
|
+
# consumer.use(JetstreamBridge::Consumer::LoggingMiddleware.new)
|
|
131
|
+
# consumer.use(JetstreamBridge::Consumer::MetricsMiddleware.new)
|
|
132
|
+
# consumer.use(JetstreamBridge::Consumer::TimeoutMiddleware.new(timeout: 30))
|
|
133
|
+
# consumer.run!
|
|
134
|
+
#
|
|
135
|
+
# @example Custom middleware
|
|
136
|
+
# class MyMiddleware
|
|
137
|
+
# def call(event)
|
|
138
|
+
# puts "Before: #{event.type}"
|
|
139
|
+
# yield
|
|
140
|
+
# puts "After: #{event.type}"
|
|
141
|
+
# end
|
|
142
|
+
# end
|
|
143
|
+
# consumer.use(MyMiddleware.new)
|
|
144
|
+
#
|
|
145
|
+
def use(middleware)
|
|
146
|
+
@middleware_chain.use(middleware)
|
|
147
|
+
self
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Start the consumer and process messages in a blocking loop.
|
|
151
|
+
#
|
|
152
|
+
# This method blocks the current thread and continuously fetches and processes
|
|
153
|
+
# messages until stop! is called or a signal is received (INT/TERM).
|
|
154
|
+
#
|
|
155
|
+
# The consumer will:
|
|
156
|
+
# - Fetch messages in batches (configurable batch_size)
|
|
157
|
+
# - Process each message through the middleware chain
|
|
158
|
+
# - Handle errors and reconnection automatically
|
|
159
|
+
# - Implement exponential backoff during idle periods
|
|
160
|
+
# - Drain in-flight messages during graceful shutdown
|
|
161
|
+
#
|
|
162
|
+
# @return [void]
|
|
163
|
+
#
|
|
164
|
+
# @example Basic usage
|
|
165
|
+
# consumer = JetstreamBridge::Consumer.new { |event| process(event) }
|
|
166
|
+
# consumer.run! # Blocks here
|
|
167
|
+
#
|
|
168
|
+
# @example In a Rake task
|
|
169
|
+
# namespace :jetstream do
|
|
170
|
+
# task consume: :environment do
|
|
171
|
+
# consumer = JetstreamBridge.subscribe { |event| handle(event) }
|
|
172
|
+
# trap("TERM") { consumer.stop! }
|
|
173
|
+
# consumer.run!
|
|
174
|
+
# end
|
|
175
|
+
# end
|
|
176
|
+
#
|
|
177
|
+
# @example With error handling
|
|
178
|
+
# consumer = JetstreamBridge::Consumer.new do |event|
|
|
179
|
+
# process(event)
|
|
180
|
+
# rescue RecoverableError => e
|
|
181
|
+
# raise # Let NATS retry
|
|
182
|
+
# rescue UnrecoverableError => e
|
|
183
|
+
# logger.error(e) # Log but don't raise (moves to DLQ if configured)
|
|
184
|
+
# end
|
|
185
|
+
# consumer.run!
|
|
186
|
+
#
|
|
43
187
|
def run!
|
|
44
188
|
Logging.info(
|
|
45
189
|
"Consumer #{@durable} started (batch=#{@batch_size}, dest=#{JetstreamBridge.config.destination_subject})…",
|
|
@@ -55,7 +199,31 @@ module JetstreamBridge
|
|
|
55
199
|
Logging.info("Consumer #{@durable} stopped gracefully", tag: 'JetstreamBridge::Consumer')
|
|
56
200
|
end
|
|
57
201
|
|
|
58
|
-
#
|
|
202
|
+
# Stop the consumer gracefully and drain in-flight messages.
|
|
203
|
+
#
|
|
204
|
+
# This method signals the consumer to stop processing new messages and drain
|
|
205
|
+
# any messages that are currently being processed. It's safe to call from
|
|
206
|
+
# signal handlers or other threads.
|
|
207
|
+
#
|
|
208
|
+
# The consumer will:
|
|
209
|
+
# - Stop fetching new messages
|
|
210
|
+
# - Complete processing of in-flight messages
|
|
211
|
+
# - Drain pending messages (up to 5 batches)
|
|
212
|
+
# - Close the subscription cleanly
|
|
213
|
+
#
|
|
214
|
+
# @return [void]
|
|
215
|
+
#
|
|
216
|
+
# @example In signal handler
|
|
217
|
+
# consumer = JetstreamBridge::Consumer.new { |event| process(event) }
|
|
218
|
+
# trap("TERM") { consumer.stop! }
|
|
219
|
+
# consumer.run!
|
|
220
|
+
#
|
|
221
|
+
# @example Manual control
|
|
222
|
+
# consumer = JetstreamBridge::Consumer.new { |event| process(event) }
|
|
223
|
+
# Thread.new { consumer.run! }
|
|
224
|
+
# sleep 10
|
|
225
|
+
# consumer.stop! # Stop after 10 seconds
|
|
226
|
+
#
|
|
59
227
|
def stop!
|
|
60
228
|
@shutdown_requested = true
|
|
61
229
|
@running = false
|
|
@@ -68,12 +68,12 @@ module JetstreamBridge
|
|
|
68
68
|
.new(deliveries, seq, @consumer, stream)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
def ack(
|
|
72
|
-
msg.ack(
|
|
71
|
+
def ack(*, **)
|
|
72
|
+
msg.ack(*, **) if msg.respond_to?(:ack)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
def nak(
|
|
76
|
-
msg.nak(
|
|
75
|
+
def nak(*, **)
|
|
76
|
+
msg.nak(*, **) if msg.respond_to?(:nak)
|
|
77
77
|
end
|
|
78
78
|
end
|
|
79
79
|
end
|
|
@@ -31,7 +31,7 @@ module JetstreamBridge
|
|
|
31
31
|
repo.persist_post(record)
|
|
32
32
|
true
|
|
33
33
|
rescue StandardError => e
|
|
34
|
-
repo.persist_failure(record, e) if
|
|
34
|
+
repo.persist_failure(record, e) if repo && record
|
|
35
35
|
Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
|
|
36
36
|
tag: 'JetstreamBridge::Consumer')
|
|
37
37
|
false
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require 'oj'
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require_relative '../core/logging'
|
|
6
|
+
require_relative '../models/event'
|
|
6
7
|
require_relative 'dlq_publisher'
|
|
8
|
+
require_relative 'middleware'
|
|
7
9
|
|
|
8
10
|
module JetstreamBridge
|
|
9
11
|
# Immutable per-message metadata.
|
|
@@ -49,11 +51,14 @@ module JetstreamBridge
|
|
|
49
51
|
class MessageProcessor
|
|
50
52
|
UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@
|
|
56
|
-
@
|
|
54
|
+
attr_reader :middleware_chain
|
|
55
|
+
|
|
56
|
+
def initialize(jts, handler, dlq: nil, backoff: nil, middleware_chain: nil)
|
|
57
|
+
@jts = jts
|
|
58
|
+
@handler = handler
|
|
59
|
+
@dlq = dlq || DlqPublisher.new(jts)
|
|
60
|
+
@backoff = backoff || BackoffStrategy.new
|
|
61
|
+
@middleware_chain = middleware_chain || ConsumerMiddleware::MiddlewareChain.new
|
|
57
62
|
end
|
|
58
63
|
|
|
59
64
|
def handle_message(msg)
|
|
@@ -97,8 +102,17 @@ module JetstreamBridge
|
|
|
97
102
|
nil
|
|
98
103
|
end
|
|
99
104
|
|
|
100
|
-
def process_event(msg,
|
|
101
|
-
|
|
105
|
+
def process_event(msg, event_hash, ctx)
|
|
106
|
+
# Convert hash to Event object
|
|
107
|
+
event = build_event_object(event_hash, ctx)
|
|
108
|
+
|
|
109
|
+
# Call handler through middleware chain
|
|
110
|
+
if @middleware_chain
|
|
111
|
+
@middleware_chain.call(event) { call_handler(event, event_hash, ctx) }
|
|
112
|
+
else
|
|
113
|
+
call_handler(event, event_hash, ctx)
|
|
114
|
+
end
|
|
115
|
+
|
|
102
116
|
msg.ack
|
|
103
117
|
Logging.info(
|
|
104
118
|
"ACK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
|
|
@@ -175,5 +189,25 @@ module JetstreamBridge
|
|
|
175
189
|
tag: 'JetstreamBridge::Consumer'
|
|
176
190
|
)
|
|
177
191
|
end
|
|
192
|
+
|
|
193
|
+
# Build Event object from hash and context
|
|
194
|
+
def build_event_object(event_hash, ctx)
|
|
195
|
+
Models::Event.new(
|
|
196
|
+
event_hash,
|
|
197
|
+
metadata: {
|
|
198
|
+
subject: ctx.subject,
|
|
199
|
+
deliveries: ctx.deliveries,
|
|
200
|
+
stream: ctx.stream,
|
|
201
|
+
sequence: ctx.seq,
|
|
202
|
+
consumer: ctx.consumer,
|
|
203
|
+
timestamp: Time.now
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Call handler with Event object
|
|
209
|
+
def call_handler(event, _event_hash, _ctx)
|
|
210
|
+
@handler.call(event)
|
|
211
|
+
end
|
|
178
212
|
end
|
|
179
213
|
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
class Consumer; end
|
|
5
|
+
|
|
6
|
+
# Middleware classes for Consumer
|
|
7
|
+
module ConsumerMiddleware
|
|
8
|
+
# Middleware chain for consumer message processing
|
|
9
|
+
#
|
|
10
|
+
# Allows you to wrap message processing with cross-cutting concerns like
|
|
11
|
+
# logging, error handling, metrics, tracing, etc.
|
|
12
|
+
#
|
|
13
|
+
# @example Using middleware
|
|
14
|
+
# consumer = JetstreamBridge::Consumer::Consumer.new(handler)
|
|
15
|
+
# consumer.use(JetstreamBridge::Consumer::LoggingMiddleware.new)
|
|
16
|
+
# consumer.use(JetstreamBridge::Consumer::MetricsMiddleware.new)
|
|
17
|
+
# consumer.run!
|
|
18
|
+
#
|
|
19
|
+
class MiddlewareChain
|
|
20
|
+
def initialize
|
|
21
|
+
@middlewares = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Add a middleware to the chain
|
|
25
|
+
#
|
|
26
|
+
# @param middleware [Object] Middleware that responds to #call(event, &block)
|
|
27
|
+
# @return [self]
|
|
28
|
+
def use(middleware)
|
|
29
|
+
@middlewares << middleware
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Execute the middleware chain
|
|
34
|
+
#
|
|
35
|
+
# @param event [Models::Event] The event to process
|
|
36
|
+
# @yield Final handler to call after all middleware
|
|
37
|
+
# @return [void]
|
|
38
|
+
def call(event, &final_handler)
|
|
39
|
+
chain = @middlewares.reverse.reduce(final_handler) do |next_middleware, middleware|
|
|
40
|
+
lambda do |evt|
|
|
41
|
+
middleware.call(evt) { next_middleware.call(evt) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
chain.call(event)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Logging middleware for consumer message processing
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# consumer.use(JetstreamBridge::Consumer::LoggingMiddleware.new)
|
|
52
|
+
#
|
|
53
|
+
class LoggingMiddleware
|
|
54
|
+
def call(event)
|
|
55
|
+
start = Time.now
|
|
56
|
+
Logging.info("Processing event #{event.event_id} (#{event.type})", tag: 'Consumer')
|
|
57
|
+
yield
|
|
58
|
+
duration = Time.now - start
|
|
59
|
+
Logging.info("Completed event #{event.event_id} in #{duration.round(3)}s", tag: 'Consumer')
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Logging.error("Failed event #{event.event_id}: #{e.message}", tag: 'Consumer')
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Error handling middleware with configurable retry logic
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# consumer.use(JetstreamBridge::Consumer::ErrorHandlingMiddleware.new(
|
|
70
|
+
# on_error: ->(event, error) { Sentry.capture_exception(error) }
|
|
71
|
+
# ))
|
|
72
|
+
#
|
|
73
|
+
class ErrorHandlingMiddleware
|
|
74
|
+
def initialize(on_error: nil)
|
|
75
|
+
@on_error = on_error
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call(event)
|
|
79
|
+
yield
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
@on_error&.call(event, e)
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Metrics middleware for tracking event processing
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# consumer.use(JetstreamBridge::Consumer::MetricsMiddleware.new(
|
|
90
|
+
# on_success: ->(event, duration) { StatsD.timing("event.process", duration) },
|
|
91
|
+
# on_failure: ->(event, error) { StatsD.increment("event.failed") }
|
|
92
|
+
# ))
|
|
93
|
+
#
|
|
94
|
+
class MetricsMiddleware
|
|
95
|
+
def initialize(on_success: nil, on_failure: nil)
|
|
96
|
+
@on_success = on_success
|
|
97
|
+
@on_failure = on_failure
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def call(event)
|
|
101
|
+
start = Time.now
|
|
102
|
+
yield
|
|
103
|
+
duration = Time.now - start
|
|
104
|
+
@on_success&.call(event, duration)
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
@on_failure&.call(event, e)
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Tracing middleware for distributed tracing
|
|
112
|
+
#
|
|
113
|
+
# @example
|
|
114
|
+
# consumer.use(JetstreamBridge::Consumer::TracingMiddleware.new)
|
|
115
|
+
#
|
|
116
|
+
class TracingMiddleware
|
|
117
|
+
def call(event)
|
|
118
|
+
trace_id = event.metadata.trace_id || event.trace_id
|
|
119
|
+
|
|
120
|
+
if defined?(ActiveSupport::CurrentAttributes)
|
|
121
|
+
# Set trace context if using Rails CurrentAttributes
|
|
122
|
+
previous_trace_id = Current.trace_id if defined?(Current)
|
|
123
|
+
Current.trace_id = trace_id if defined?(Current)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
yield
|
|
127
|
+
ensure
|
|
128
|
+
Current.trace_id = previous_trace_id if defined?(Current) && defined?(previous_trace_id)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Timeout middleware to prevent long-running handlers
|
|
133
|
+
#
|
|
134
|
+
# @example
|
|
135
|
+
# consumer.use(JetstreamBridge::Consumer::TimeoutMiddleware.new(timeout: 30))
|
|
136
|
+
#
|
|
137
|
+
class TimeoutMiddleware
|
|
138
|
+
def initialize(timeout: 30)
|
|
139
|
+
@timeout = timeout
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def call(event, &)
|
|
143
|
+
require 'timeout'
|
|
144
|
+
Timeout.timeout(@timeout, &)
|
|
145
|
+
rescue Timeout::Error
|
|
146
|
+
raise ConsumerError.new(
|
|
147
|
+
"Event processing timeout after #{@timeout}s",
|
|
148
|
+
event_id: event.event_id,
|
|
149
|
+
deliveries: event.deliveries
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -3,11 +3,89 @@
|
|
|
3
3
|
require_relative '../errors'
|
|
4
4
|
|
|
5
5
|
module JetstreamBridge
|
|
6
|
+
# Configuration object for JetStream Bridge.
|
|
7
|
+
#
|
|
8
|
+
# Holds all configuration settings including NATS connection details,
|
|
9
|
+
# application identifiers, reliability features, and consumer tuning.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic configuration
|
|
12
|
+
# JetstreamBridge.configure do |config|
|
|
13
|
+
# config.nats_urls = "nats://localhost:4222"
|
|
14
|
+
# config.env = "production"
|
|
15
|
+
# config.app_name = "api_service"
|
|
16
|
+
# config.destination_app = "worker_service"
|
|
17
|
+
# config.use_outbox = true
|
|
18
|
+
# config.use_inbox = true
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Using a preset
|
|
22
|
+
# JetstreamBridge.configure_for(:production) do |config|
|
|
23
|
+
# config.nats_urls = ENV["NATS_URLS"]
|
|
24
|
+
# config.app_name = "api"
|
|
25
|
+
# config.destination_app = "worker"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
6
28
|
class Config
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
29
|
+
# Status constants for event processing states.
|
|
30
|
+
module Status
|
|
31
|
+
# Event queued in outbox, not yet published
|
|
32
|
+
PENDING = 'pending'
|
|
33
|
+
# Event currently being published to NATS
|
|
34
|
+
PUBLISHING = 'publishing'
|
|
35
|
+
# Event successfully published to NATS
|
|
36
|
+
SENT = 'sent'
|
|
37
|
+
# Event failed to publish after retries
|
|
38
|
+
FAILED = 'failed'
|
|
39
|
+
# Event received by consumer
|
|
40
|
+
RECEIVED = 'received'
|
|
41
|
+
# Event currently being processed by consumer
|
|
42
|
+
PROCESSING = 'processing'
|
|
43
|
+
# Event successfully processed by consumer
|
|
44
|
+
PROCESSED = 'processed'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# NATS server URL(s), comma-separated for multiple servers
|
|
48
|
+
# @return [String]
|
|
49
|
+
attr_accessor :destination_app
|
|
50
|
+
# NATS server URL(s)
|
|
51
|
+
# @return [String]
|
|
52
|
+
attr_accessor :nats_urls
|
|
53
|
+
# Environment namespace (development, staging, production)
|
|
54
|
+
# @return [String]
|
|
55
|
+
attr_accessor :env
|
|
56
|
+
# Application name for subject routing
|
|
57
|
+
# @return [String]
|
|
58
|
+
attr_accessor :app_name
|
|
59
|
+
# Maximum delivery attempts before moving to DLQ
|
|
60
|
+
# @return [Integer]
|
|
61
|
+
attr_accessor :max_deliver
|
|
62
|
+
# Time to wait for acknowledgment before redelivery
|
|
63
|
+
# @return [String, Integer]
|
|
64
|
+
attr_accessor :ack_wait
|
|
65
|
+
# Backoff delays between retries
|
|
66
|
+
# @return [Array<String>]
|
|
67
|
+
attr_accessor :backoff
|
|
68
|
+
# Enable transactional outbox pattern
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
attr_accessor :use_outbox
|
|
71
|
+
# Enable idempotent inbox pattern
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
attr_accessor :use_inbox
|
|
74
|
+
# ActiveRecord model class name for inbox events
|
|
75
|
+
# @return [String]
|
|
76
|
+
attr_accessor :inbox_model
|
|
77
|
+
# ActiveRecord model class name for outbox events
|
|
78
|
+
# @return [String]
|
|
79
|
+
attr_accessor :outbox_model
|
|
80
|
+
# Enable dead letter queue
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
attr_accessor :use_dlq
|
|
83
|
+
# Logger instance
|
|
84
|
+
# @return [Logger, nil]
|
|
85
|
+
attr_accessor :logger
|
|
86
|
+
# Applied preset name
|
|
87
|
+
# @return [Symbol, nil]
|
|
88
|
+
attr_reader :preset_applied
|
|
11
89
|
|
|
12
90
|
def initialize
|
|
13
91
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
@@ -25,16 +103,43 @@ module JetstreamBridge
|
|
|
25
103
|
@outbox_model = 'JetstreamBridge::OutboxEvent'
|
|
26
104
|
@inbox_model = 'JetstreamBridge::InboxEvent'
|
|
27
105
|
@logger = nil
|
|
106
|
+
@preset_applied = nil
|
|
28
107
|
end
|
|
29
108
|
|
|
30
|
-
#
|
|
109
|
+
# Apply a configuration preset
|
|
110
|
+
#
|
|
111
|
+
# @param preset_name [Symbol, String] Name of preset (e.g., :production, :development)
|
|
112
|
+
# @return [self]
|
|
113
|
+
def apply_preset(preset_name)
|
|
114
|
+
require_relative 'config_preset'
|
|
115
|
+
ConfigPreset.apply(self, preset_name)
|
|
116
|
+
@preset_applied = preset_name.to_sym
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Get the JetStream stream name for this environment.
|
|
121
|
+
#
|
|
122
|
+
# @return [String] Stream name in format "{env}-jetstream-bridge-stream"
|
|
123
|
+
# @example
|
|
124
|
+
# config.env = "production"
|
|
125
|
+
# config.stream_name # => "production-jetstream-bridge-stream"
|
|
31
126
|
def stream_name
|
|
32
127
|
"#{env}-jetstream-bridge-stream"
|
|
33
128
|
end
|
|
34
129
|
|
|
35
|
-
#
|
|
130
|
+
# Get the NATS subject this application publishes to.
|
|
131
|
+
#
|
|
36
132
|
# Producer publishes to: {env}.{app}.sync.{dest}
|
|
37
133
|
# Consumer subscribes to: {env}.{dest}.sync.{app}
|
|
134
|
+
#
|
|
135
|
+
# @return [String] Source subject for publishing
|
|
136
|
+
# @raise [InvalidSubjectError] If components contain NATS wildcards
|
|
137
|
+
# @raise [MissingConfigurationError] If required components empty
|
|
138
|
+
# @example
|
|
139
|
+
# config.env = "production"
|
|
140
|
+
# config.app_name = "api"
|
|
141
|
+
# config.destination_app = "worker"
|
|
142
|
+
# config.source_subject # => "production.api.sync.worker"
|
|
38
143
|
def source_subject
|
|
39
144
|
validate_subject_component!(env, 'env')
|
|
40
145
|
validate_subject_component!(app_name, 'app_name')
|
|
@@ -42,6 +147,16 @@ module JetstreamBridge
|
|
|
42
147
|
"#{env}.#{app_name}.sync.#{destination_app}"
|
|
43
148
|
end
|
|
44
149
|
|
|
150
|
+
# Get the NATS subject this application subscribes to.
|
|
151
|
+
#
|
|
152
|
+
# @return [String] Destination subject for consuming
|
|
153
|
+
# @raise [InvalidSubjectError] If components contain NATS wildcards
|
|
154
|
+
# @raise [MissingConfigurationError] If required components empty
|
|
155
|
+
# @example
|
|
156
|
+
# config.env = "production"
|
|
157
|
+
# config.app_name = "api"
|
|
158
|
+
# config.destination_app = "worker"
|
|
159
|
+
# config.destination_subject # => "production.worker.sync.api"
|
|
45
160
|
def destination_subject
|
|
46
161
|
validate_subject_component!(env, 'env')
|
|
47
162
|
validate_subject_component!(app_name, 'app_name')
|
|
@@ -49,17 +164,43 @@ module JetstreamBridge
|
|
|
49
164
|
"#{env}.#{destination_app}.sync.#{app_name}"
|
|
50
165
|
end
|
|
51
166
|
|
|
52
|
-
#
|
|
167
|
+
# Get the dead letter queue subject for this application.
|
|
168
|
+
#
|
|
169
|
+
# Each app has its own DLQ for better isolation and monitoring.
|
|
170
|
+
#
|
|
171
|
+
# @return [String] DLQ subject in format "{env}.{app_name}.sync.dlq"
|
|
172
|
+
# @raise [InvalidSubjectError] If components contain NATS wildcards
|
|
173
|
+
# @raise [MissingConfigurationError] If required components are empty
|
|
174
|
+
# @example
|
|
175
|
+
# config.env = "production"
|
|
176
|
+
# config.app_name = "api"
|
|
177
|
+
# config.dlq_subject # => "production.api.sync.dlq"
|
|
53
178
|
def dlq_subject
|
|
54
179
|
validate_subject_component!(env, 'env')
|
|
55
|
-
|
|
180
|
+
validate_subject_component!(app_name, 'app_name')
|
|
181
|
+
"#{env}.#{app_name}.sync.dlq"
|
|
56
182
|
end
|
|
57
183
|
|
|
184
|
+
# Get the durable consumer name for this application.
|
|
185
|
+
#
|
|
186
|
+
# @return [String] Durable name in format "{env}-{app_name}-workers"
|
|
187
|
+
# @example
|
|
188
|
+
# config.env = "production"
|
|
189
|
+
# config.app_name = "api"
|
|
190
|
+
# config.durable_name # => "production-api-workers"
|
|
58
191
|
def durable_name
|
|
59
192
|
"#{env}-#{app_name}-workers"
|
|
60
193
|
end
|
|
61
194
|
|
|
62
|
-
# Validate configuration settings
|
|
195
|
+
# Validate all configuration settings.
|
|
196
|
+
#
|
|
197
|
+
# Checks that required settings are present and valid. Raises errors
|
|
198
|
+
# for any invalid configuration.
|
|
199
|
+
#
|
|
200
|
+
# @return [true] If configuration is valid
|
|
201
|
+
# @raise [ConfigurationError] If any validation fails
|
|
202
|
+
# @example
|
|
203
|
+
# config.validate! # Raises if destination_app is missing
|
|
63
204
|
def validate!
|
|
64
205
|
errors = []
|
|
65
206
|
errors << 'destination_app is required' if destination_app.to_s.strip.empty?
|