jetstream_bridge 4.0.3 → 4.1.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 +67 -0
- data/README.md +28 -3
- data/lib/jetstream_bridge/consumer/consumer.rb +87 -3
- data/lib/jetstream_bridge/core/config.rb +27 -4
- data/lib/jetstream_bridge/core/connection.rb +145 -13
- data/lib/jetstream_bridge/core/debug_helper.rb +24 -12
- data/lib/jetstream_bridge/models/inbox_event.rb +11 -5
- data/lib/jetstream_bridge/publisher/publisher.rb +5 -2
- data/lib/jetstream_bridge/railtie.rb +49 -7
- data/lib/jetstream_bridge/test_helpers/mock_nats.rb +524 -0
- data/lib/jetstream_bridge/test_helpers.rb +221 -2
- data/lib/jetstream_bridge/topology/overlap_guard.rb +50 -1
- data/lib/jetstream_bridge/topology/stream.rb +14 -6
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +170 -14
- metadata +12 -5
|
@@ -36,11 +36,14 @@ module JetstreamBridge
|
|
|
36
36
|
class Publisher
|
|
37
37
|
# Initialize a new Publisher instance.
|
|
38
38
|
#
|
|
39
|
+
# Note: The NATS connection should already be established via JetstreamBridge.configure.
|
|
40
|
+
# If not, this will attempt to connect, but it's recommended to call configure first.
|
|
41
|
+
#
|
|
39
42
|
# @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
|
|
40
43
|
# Defaults to PublisherRetryStrategy with exponential backoff.
|
|
41
44
|
# @raise [ConnectionError] If unable to connect to NATS server
|
|
42
45
|
def initialize(retry_strategy: nil)
|
|
43
|
-
@jts = Connection.connect!
|
|
46
|
+
@jts = Connection.jetstream || Connection.connect!
|
|
44
47
|
@retry_strategy = retry_strategy || PublisherRetryStrategy.new
|
|
45
48
|
end
|
|
46
49
|
|
|
@@ -290,7 +293,7 @@ module JetstreamBridge
|
|
|
290
293
|
'schema_version' => 1,
|
|
291
294
|
'event_type' => event_type,
|
|
292
295
|
'producer' => JetstreamBridge.config.app_name,
|
|
293
|
-
'resource_id' => (payload
|
|
296
|
+
'resource_id' => extract_resource_id(payload),
|
|
294
297
|
'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
|
|
295
298
|
'trace_id' => options[:trace_id] || SecureRandom.hex(8),
|
|
296
299
|
'resource_type' => resource_type,
|
|
@@ -4,8 +4,16 @@ require_relative 'core/model_codec_setup'
|
|
|
4
4
|
require_relative 'core/logging'
|
|
5
5
|
|
|
6
6
|
module JetstreamBridge
|
|
7
|
+
# Rails integration for JetStream Bridge.
|
|
8
|
+
#
|
|
9
|
+
# This Railtie integrates JetStream Bridge with the Rails application lifecycle:
|
|
10
|
+
# - Startup: Connection is established when Rails initializers run (via configure)
|
|
11
|
+
# - Shutdown: Connection is closed when Rails shuts down (at_exit hook)
|
|
12
|
+
# - Restart: Puma/Unicorn workers get fresh connections on fork
|
|
13
|
+
#
|
|
7
14
|
class Railtie < ::Rails::Railtie
|
|
8
15
|
# Set up logger to use Rails.logger by default
|
|
16
|
+
# Note: configure() will call startup! which establishes the connection
|
|
9
17
|
initializer 'jetstream_bridge.logger', before: :initialize_logger do
|
|
10
18
|
JetstreamBridge.configure do |config|
|
|
11
19
|
config.logger ||= Rails.logger if defined?(Rails.logger)
|
|
@@ -19,14 +27,47 @@ module JetstreamBridge
|
|
|
19
27
|
end
|
|
20
28
|
end
|
|
21
29
|
|
|
22
|
-
# Validate configuration
|
|
23
|
-
initializer 'jetstream_bridge.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
# Validate configuration and setup environment-specific behavior
|
|
31
|
+
initializer 'jetstream_bridge.setup', after: :load_config_initializers do |app|
|
|
32
|
+
app.config.after_initialize do
|
|
33
|
+
# Validate configuration in development/test
|
|
34
|
+
if Rails.env.development? || Rails.env.test?
|
|
35
|
+
begin
|
|
36
|
+
JetstreamBridge.config.validate! if JetstreamBridge.config.destination_app
|
|
37
|
+
rescue JetstreamBridge::ConfigurationError => e
|
|
38
|
+
Rails.logger.warn "[JetStream Bridge] Configuration warning: #{e.message}"
|
|
39
|
+
end
|
|
29
40
|
end
|
|
41
|
+
|
|
42
|
+
# Auto-enable test mode in test environment if NATS_URLS not set
|
|
43
|
+
if Rails.env.test? && ENV['NATS_URLS'].blank? && !(defined?(JetstreamBridge::TestHelpers) &&
|
|
44
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
45
|
+
JetstreamBridge::TestHelpers.test_mode?)
|
|
46
|
+
Rails.logger.info '[JetStream Bridge] Auto-enabling test mode (NATS_URLS not set)'
|
|
47
|
+
require_relative 'test_helpers'
|
|
48
|
+
JetstreamBridge::TestHelpers.enable_test_mode!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Log helpful connection info in development
|
|
52
|
+
if Rails.env.development? && JetstreamBridge.connected?
|
|
53
|
+
conn_state = JetstreamBridge::Connection.instance.state
|
|
54
|
+
Rails.logger.info "[JetStream Bridge] Connection state: #{conn_state}"
|
|
55
|
+
Rails.logger.info "[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}"
|
|
56
|
+
Rails.logger.info "[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}"
|
|
57
|
+
Rails.logger.info "[JetStream Bridge] Publishing to: #{JetstreamBridge.config.source_subject}"
|
|
58
|
+
Rails.logger.info "[JetStream Bridge] Consuming from: #{JetstreamBridge.config.destination_subject}"
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
# Don't fail app initialization for logging errors
|
|
62
|
+
Rails.logger.debug "[JetStream Bridge] Setup logging skipped: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Register shutdown hook for graceful cleanup
|
|
67
|
+
# This runs when Rails shuts down (Ctrl+C, SIGTERM, etc.)
|
|
68
|
+
config.after_initialize do
|
|
69
|
+
at_exit do
|
|
70
|
+
JetstreamBridge.shutdown!
|
|
30
71
|
end
|
|
31
72
|
end
|
|
32
73
|
|
|
@@ -34,6 +75,7 @@ module JetstreamBridge
|
|
|
34
75
|
console do
|
|
35
76
|
Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
|
|
36
77
|
Rails.logger.info '[JetStream Bridge] Use JetstreamBridge.health_check to check status'
|
|
78
|
+
Rails.logger.info '[JetStream Bridge] Use JetstreamBridge.shutdown! to gracefully disconnect'
|
|
37
79
|
end
|
|
38
80
|
|
|
39
81
|
# Load rake tasks
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# In-memory mock for NATS JetStream connection
|
|
6
|
+
# Simulates the NATS::IO::Client and JetStream API without requiring a real server
|
|
7
|
+
module MockNats
|
|
8
|
+
class MockConnection
|
|
9
|
+
attr_reader :connected_at, :callbacks
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@connected = false
|
|
13
|
+
@connected_at = nil
|
|
14
|
+
@callbacks = { reconnect: [], disconnect: [], error: [] }
|
|
15
|
+
# Use global storage to ensure persistence across test helper calls
|
|
16
|
+
@jetstream = MockJetStream.new(MockNats.storage)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def connect(_urls = nil, **_options)
|
|
20
|
+
@connected = true
|
|
21
|
+
@connected_at = Time.now
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def jetstream
|
|
26
|
+
raise NATS::IO::NoRespondersError, 'JetStream not available' unless @connected
|
|
27
|
+
|
|
28
|
+
@jetstream
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def connected?
|
|
32
|
+
@connected
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_reconnect(&block)
|
|
36
|
+
@callbacks[:reconnect] << block if block
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_disconnect(&block)
|
|
40
|
+
@callbacks[:disconnect] << block if block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def on_error(&block)
|
|
44
|
+
@callbacks[:error] << block if block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def rtt
|
|
48
|
+
0.001 # 1ms simulated round-trip time
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Simulate JetStream API requests
|
|
52
|
+
# Used by overlap_guard.rb for stream management operations
|
|
53
|
+
def request(subject, _payload, timeout: 1)
|
|
54
|
+
raise NATS::IO::NoRespondersError, 'Not connected' unless @connected
|
|
55
|
+
|
|
56
|
+
# Parse the API request
|
|
57
|
+
response_data = case subject
|
|
58
|
+
when '$JS.API.STREAM.NAMES'
|
|
59
|
+
# Return list of stream names
|
|
60
|
+
stream_names = @jetstream.storage.streams.keys.map { |name| { 'name' => name } }
|
|
61
|
+
{
|
|
62
|
+
'type' => 'io.nats.jetstream.api.v1.stream_names_response',
|
|
63
|
+
'total' => stream_names.size,
|
|
64
|
+
'offset' => 0,
|
|
65
|
+
'limit' => 1024,
|
|
66
|
+
'streams' => stream_names
|
|
67
|
+
}
|
|
68
|
+
when /^\$JS\.API\.STREAM\.INFO\.(.+)$/
|
|
69
|
+
# Return stream info for specific stream
|
|
70
|
+
stream_name = ::Regexp.last_match(1)
|
|
71
|
+
stream = @jetstream.storage.find_stream(stream_name)
|
|
72
|
+
if stream
|
|
73
|
+
info = stream.info
|
|
74
|
+
{
|
|
75
|
+
'type' => 'io.nats.jetstream.api.v1.stream_info_response',
|
|
76
|
+
'config' => {
|
|
77
|
+
'name' => info.config.name,
|
|
78
|
+
'subjects' => info.config.subjects
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
{
|
|
83
|
+
'error' => {
|
|
84
|
+
'code' => 404,
|
|
85
|
+
'description' => 'stream not found'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
# Generic response for unknown API calls
|
|
91
|
+
{ 'type' => 'io.nats.jetstream.api.v1.response' }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Return a mock message object with the response data
|
|
95
|
+
MockApiResponse.new(Oj.dump(response_data, mode: :compat))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def close
|
|
99
|
+
@connected = false
|
|
100
|
+
@callbacks[:disconnect].each(&:call)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Test helpers for simulating connection events
|
|
104
|
+
def simulate_disconnect!
|
|
105
|
+
@connected = false
|
|
106
|
+
@callbacks[:disconnect].each(&:call)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def simulate_reconnect!
|
|
110
|
+
@connected = true
|
|
111
|
+
@callbacks[:reconnect].each(&:call)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def simulate_error!(error)
|
|
115
|
+
@callbacks[:error].each { |cb| cb.call(error) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class MockJetStream
|
|
120
|
+
attr_reader :storage
|
|
121
|
+
|
|
122
|
+
def initialize(storage = nil)
|
|
123
|
+
@storage = storage || MockNats.storage
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def account_info
|
|
127
|
+
OpenStruct.new(
|
|
128
|
+
memory: 1024 * 1024 * 100,
|
|
129
|
+
storage: 1024 * 1024 * 1000,
|
|
130
|
+
streams: @storage.streams.count,
|
|
131
|
+
consumers: @storage.consumers.count
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def publish(subject, data, header: {})
|
|
136
|
+
@storage.publish(subject, data, header)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pull_subscribe(subject, durable_name, **options)
|
|
140
|
+
@storage.create_subscription(subject, durable_name, options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def consumer_info(stream_name, durable_name)
|
|
144
|
+
consumer = @storage.find_consumer(stream_name, durable_name)
|
|
145
|
+
raise NATS::JetStream::Error, 'consumer not found' unless consumer
|
|
146
|
+
|
|
147
|
+
consumer.info
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def stream_info(stream_name)
|
|
151
|
+
stream = @storage.find_stream(stream_name)
|
|
152
|
+
raise NATS::JetStream::Error, 'stream not found' unless stream
|
|
153
|
+
|
|
154
|
+
stream.info
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def add_stream(config)
|
|
158
|
+
@storage.add_stream(config)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def delete_stream(name)
|
|
162
|
+
@storage.delete_stream(name)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def add_consumer(stream_name, **config)
|
|
166
|
+
@storage.add_consumer(stream_name, config)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def delete_consumer(stream_name, consumer_name)
|
|
170
|
+
@storage.delete_consumer(stream_name, consumer_name)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class InMemoryStorage
|
|
175
|
+
attr_reader :streams, :consumers, :messages, :subscriptions
|
|
176
|
+
|
|
177
|
+
def initialize
|
|
178
|
+
@streams = {}
|
|
179
|
+
@consumers = {}
|
|
180
|
+
@messages = []
|
|
181
|
+
@subscriptions = {}
|
|
182
|
+
@sequence_counter = 0
|
|
183
|
+
@mutex = Mutex.new
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def publish(subject, data, header)
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
event_id = header['nats-msg-id'] || SecureRandom.uuid
|
|
189
|
+
|
|
190
|
+
# Check for duplicate
|
|
191
|
+
duplicate = @messages.any? { |msg| msg[:header]['nats-msg-id'] == event_id }
|
|
192
|
+
|
|
193
|
+
unless duplicate
|
|
194
|
+
@sequence_counter += 1
|
|
195
|
+
@messages << {
|
|
196
|
+
subject: subject,
|
|
197
|
+
data: data,
|
|
198
|
+
header: header,
|
|
199
|
+
sequence: @sequence_counter,
|
|
200
|
+
timestamp: Time.now,
|
|
201
|
+
delivery_count: 0
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
MockAck.new(
|
|
206
|
+
duplicate: duplicate,
|
|
207
|
+
sequence: @sequence_counter,
|
|
208
|
+
stream: find_stream_for_subject(subject)&.name || 'mock-stream'
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def create_subscription(subject, durable_name, options)
|
|
214
|
+
@mutex.synchronize do
|
|
215
|
+
stream_name = options[:stream] || find_stream_for_subject(subject)&.name || 'mock-stream'
|
|
216
|
+
|
|
217
|
+
subscription = MockSubscription.new(
|
|
218
|
+
subject: subject,
|
|
219
|
+
durable_name: durable_name,
|
|
220
|
+
storage: self,
|
|
221
|
+
stream_name: stream_name,
|
|
222
|
+
options: options
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@subscriptions[durable_name] = subscription
|
|
226
|
+
|
|
227
|
+
# Register consumer
|
|
228
|
+
@consumers[durable_name] = MockConsumer.new(
|
|
229
|
+
name: durable_name,
|
|
230
|
+
stream: stream_name,
|
|
231
|
+
config: options
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
subscription
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def fetch_messages(subject, durable_name, batch_size, _timeout)
|
|
239
|
+
@mutex.synchronize do
|
|
240
|
+
consumer = @consumers[durable_name]
|
|
241
|
+
stream_name = consumer&.stream || 'mock-stream'
|
|
242
|
+
|
|
243
|
+
# Find messages matching the subject
|
|
244
|
+
matching = @messages.select do |msg|
|
|
245
|
+
msg[:subject] == subject && msg[:delivery_count] < max_deliver_for(durable_name)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Take up to batch_size messages
|
|
249
|
+
to_deliver = matching.first(batch_size)
|
|
250
|
+
|
|
251
|
+
# Increment delivery count and return MockMessage objects
|
|
252
|
+
to_deliver.map do |msg|
|
|
253
|
+
msg[:delivery_count] += 1
|
|
254
|
+
|
|
255
|
+
MockMessage.new(
|
|
256
|
+
subject: msg[:subject],
|
|
257
|
+
data: msg[:data],
|
|
258
|
+
header: msg[:header],
|
|
259
|
+
sequence: msg[:sequence],
|
|
260
|
+
stream: stream_name,
|
|
261
|
+
consumer: durable_name,
|
|
262
|
+
num_delivered: msg[:delivery_count],
|
|
263
|
+
storage: self,
|
|
264
|
+
message_ref: msg
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def ack_message(message_ref)
|
|
271
|
+
@mutex.synchronize do
|
|
272
|
+
@messages.delete(message_ref)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def nak_message(_message_ref, delay: nil)
|
|
277
|
+
@mutex.synchronize do
|
|
278
|
+
# Message stays in queue for redelivery
|
|
279
|
+
# Note: delivery_count was already incremented during fetch
|
|
280
|
+
# We don't decrement it here as it represents actual delivery attempts
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def term_message(message_ref)
|
|
285
|
+
@mutex.synchronize do
|
|
286
|
+
@messages.delete(message_ref)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def add_stream(config)
|
|
291
|
+
@mutex.synchronize do
|
|
292
|
+
name = config[:name] || config['name']
|
|
293
|
+
@streams[name] = MockStream.new(name, config)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def delete_stream(name)
|
|
298
|
+
@mutex.synchronize do
|
|
299
|
+
@streams.delete(name)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def find_stream(name)
|
|
304
|
+
@streams[name]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def find_stream_for_subject(_subject)
|
|
308
|
+
@streams.values.first # Simplified: return first stream
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def find_consumer(stream_name, durable_name)
|
|
312
|
+
consumer = @consumers[durable_name]
|
|
313
|
+
return nil unless consumer
|
|
314
|
+
return nil unless consumer.stream == stream_name
|
|
315
|
+
|
|
316
|
+
consumer
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def add_consumer(stream_name, config)
|
|
320
|
+
@mutex.synchronize do
|
|
321
|
+
durable_name = config[:durable_name] || config['durable_name']
|
|
322
|
+
@consumers[durable_name] = MockConsumer.new(
|
|
323
|
+
name: durable_name,
|
|
324
|
+
stream: stream_name,
|
|
325
|
+
config: config
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def delete_consumer(stream_name, consumer_name)
|
|
331
|
+
@mutex.synchronize do
|
|
332
|
+
consumer = @consumers[consumer_name]
|
|
333
|
+
@consumers.delete(consumer_name) if consumer&.stream == stream_name
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def reset!
|
|
338
|
+
@mutex.synchronize do
|
|
339
|
+
@streams.clear
|
|
340
|
+
@consumers.clear
|
|
341
|
+
@messages.clear
|
|
342
|
+
@subscriptions.clear
|
|
343
|
+
@sequence_counter = 0
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private
|
|
348
|
+
|
|
349
|
+
def max_deliver_for(durable_name)
|
|
350
|
+
consumer = @consumers[durable_name]
|
|
351
|
+
return 5 unless consumer # Default
|
|
352
|
+
|
|
353
|
+
consumer.config[:max_deliver] || consumer.config['max_deliver'] || 5
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
class MockAck
|
|
358
|
+
attr_reader :sequence, :stream, :error
|
|
359
|
+
|
|
360
|
+
def initialize(duplicate:, sequence:, stream:)
|
|
361
|
+
@duplicate = duplicate
|
|
362
|
+
@sequence = sequence
|
|
363
|
+
@stream = stream
|
|
364
|
+
@error = nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def duplicate?
|
|
368
|
+
@duplicate
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
class MockMessage
|
|
373
|
+
attr_reader :subject, :data, :header, :sequence, :stream, :consumer, :num_delivered
|
|
374
|
+
|
|
375
|
+
def initialize(subject:, data:, header:, sequence:, stream:, consumer:, num_delivered:, storage:, message_ref:)
|
|
376
|
+
@subject = subject
|
|
377
|
+
@data = data
|
|
378
|
+
@header = header
|
|
379
|
+
@sequence = sequence
|
|
380
|
+
@stream = stream
|
|
381
|
+
@consumer = consumer
|
|
382
|
+
@num_delivered = num_delivered
|
|
383
|
+
@storage = storage
|
|
384
|
+
@message_ref = message_ref
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def metadata
|
|
388
|
+
OpenStruct.new(
|
|
389
|
+
sequence: OpenStruct.new(stream: @sequence, consumer: @sequence),
|
|
390
|
+
num_delivered: @num_delivered,
|
|
391
|
+
stream: @stream,
|
|
392
|
+
consumer: @consumer,
|
|
393
|
+
timestamp: Time.now
|
|
394
|
+
)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def ack
|
|
398
|
+
@storage.ack_message(@message_ref)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def nak(in_progress_duration: nil)
|
|
402
|
+
@storage.nak_message(@message_ref, delay: in_progress_duration)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def term
|
|
406
|
+
@storage.term_message(@message_ref)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
class MockSubscription
|
|
411
|
+
attr_reader :subject, :durable_name, :stream_name
|
|
412
|
+
|
|
413
|
+
def initialize(subject:, durable_name:, storage:, stream_name:, options:)
|
|
414
|
+
@subject = subject
|
|
415
|
+
@durable_name = durable_name
|
|
416
|
+
@storage = storage
|
|
417
|
+
@stream_name = stream_name
|
|
418
|
+
@options = options
|
|
419
|
+
@unsubscribed = false
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def fetch(batch_size, timeout: 5)
|
|
423
|
+
raise NATS::JetStream::Error, 'consumer not found' if @unsubscribed
|
|
424
|
+
|
|
425
|
+
@storage.fetch_messages(@subject, @durable_name, batch_size, timeout)
|
|
426
|
+
rescue StandardError => e
|
|
427
|
+
raise NATS::IO::Timeout if timeout && e.is_a?(Timeout::Error)
|
|
428
|
+
|
|
429
|
+
raise
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def unsubscribe
|
|
433
|
+
@unsubscribed = true
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
class MockStream
|
|
438
|
+
attr_reader :name, :config
|
|
439
|
+
|
|
440
|
+
def initialize(name, config)
|
|
441
|
+
@name = name
|
|
442
|
+
@config = config
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def info
|
|
446
|
+
OpenStruct.new(
|
|
447
|
+
config: OpenStruct.new(
|
|
448
|
+
name: @name,
|
|
449
|
+
subjects: @config[:subjects] || [@name],
|
|
450
|
+
retention: @config[:retention] || 'limits',
|
|
451
|
+
max_consumers: @config[:max_consumers] || -1,
|
|
452
|
+
max_msgs: @config[:max_msgs] || -1,
|
|
453
|
+
max_bytes: @config[:max_bytes] || -1,
|
|
454
|
+
discard: @config[:discard] || 'old',
|
|
455
|
+
max_age: @config[:max_age] || 0,
|
|
456
|
+
max_msgs_per_subject: @config[:max_msgs_per_subject] || -1,
|
|
457
|
+
max_msg_size: @config[:max_msg_size] || -1,
|
|
458
|
+
storage: @config[:storage] || 'file',
|
|
459
|
+
num_replicas: @config[:num_replicas] || 1
|
|
460
|
+
),
|
|
461
|
+
state: OpenStruct.new(
|
|
462
|
+
messages: 0,
|
|
463
|
+
bytes: 0,
|
|
464
|
+
first_seq: 1,
|
|
465
|
+
last_seq: 0,
|
|
466
|
+
consumer_count: 0
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
class MockConsumer
|
|
473
|
+
attr_reader :name, :stream, :config
|
|
474
|
+
|
|
475
|
+
def initialize(name:, stream:, config:)
|
|
476
|
+
@name = name
|
|
477
|
+
@stream = stream
|
|
478
|
+
@config = config
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def info
|
|
482
|
+
OpenStruct.new(
|
|
483
|
+
name: @name,
|
|
484
|
+
stream_name: @stream,
|
|
485
|
+
config: OpenStruct.new(
|
|
486
|
+
durable_name: @name,
|
|
487
|
+
ack_policy: @config[:ack_policy] || 'explicit',
|
|
488
|
+
max_deliver: @config[:max_deliver] || 5,
|
|
489
|
+
ack_wait: @config[:ack_wait] || 30_000_000_000,
|
|
490
|
+
filter_subject: @config[:filter_subject] || '',
|
|
491
|
+
replay_policy: @config[:replay_policy] || 'instant'
|
|
492
|
+
),
|
|
493
|
+
num_pending: 0,
|
|
494
|
+
num_delivered: 0
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Mock API response message
|
|
500
|
+
# Simulates NATS::Msg for JetStream API responses
|
|
501
|
+
class MockApiResponse
|
|
502
|
+
attr_reader :data
|
|
503
|
+
|
|
504
|
+
def initialize(data)
|
|
505
|
+
@data = data
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Factory method to create a mock connection
|
|
510
|
+
def self.create_mock_connection
|
|
511
|
+
MockConnection.new
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Global storage accessor for testing
|
|
515
|
+
def self.storage
|
|
516
|
+
@storage ||= InMemoryStorage.new
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def self.reset!
|
|
520
|
+
@storage&.reset!
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|