jetstream_bridge 4.4.0 → 4.5.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 +92 -337
- data/README.md +1 -5
- data/docs/GETTING_STARTED.md +11 -7
- data/docs/PRODUCTION.md +51 -11
- data/docs/TESTING.md +24 -35
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +1 -1
- data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +1 -1
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +0 -4
- data/lib/generators/jetstream_bridge/install/install_generator.rb +5 -5
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +2 -2
- data/lib/jetstream_bridge/consumer/consumer.rb +34 -96
- data/lib/jetstream_bridge/consumer/health_monitor.rb +107 -0
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +51 -34
- data/lib/jetstream_bridge/core/config.rb +153 -46
- data/lib/jetstream_bridge/core/connection_manager.rb +513 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +9 -3
- data/lib/jetstream_bridge/core/health_checker.rb +184 -0
- data/lib/jetstream_bridge/core.rb +0 -2
- data/lib/jetstream_bridge/facade.rb +212 -0
- data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +110 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +87 -117
- data/lib/jetstream_bridge/rails/integration.rb +8 -5
- data/lib/jetstream_bridge/rails/railtie.rb +4 -3
- data/lib/jetstream_bridge/tasks/install.rake +0 -1
- data/lib/jetstream_bridge/topology/topology.rb +6 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +206 -297
- metadata +7 -5
- data/lib/jetstream_bridge/core/bridge_helpers.rb +0 -109
- data/lib/jetstream_bridge/core/connection.rb +0 -464
- data/lib/jetstream_bridge/core/connection_factory.rb +0 -100
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require 'oj'
|
|
4
4
|
require 'securerandom'
|
|
5
|
-
require_relative '../core/connection'
|
|
6
5
|
require_relative '../core/logging'
|
|
7
6
|
require_relative '../core/config'
|
|
8
7
|
require_relative '../core/model_utils'
|
|
9
8
|
require_relative '../core/retry_strategy'
|
|
10
9
|
require_relative '../models/publish_result'
|
|
11
10
|
require_relative 'outbox_repository'
|
|
11
|
+
require_relative 'event_envelope_builder'
|
|
12
12
|
|
|
13
13
|
module JetstreamBridge
|
|
14
14
|
# Publishes events to NATS JetStream with reliability features.
|
|
@@ -34,19 +34,20 @@ module JetstreamBridge
|
|
|
34
34
|
# JetstreamBridge.publish(event_type: "user.created", payload: { id: 1 })
|
|
35
35
|
#
|
|
36
36
|
class Publisher
|
|
37
|
-
# Initialize a new Publisher instance.
|
|
37
|
+
# Initialize a new Publisher instance with dependency injection.
|
|
38
38
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# @
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
# @param connection [NATS::JetStream::JS] JetStream connection
|
|
40
|
+
# @param config [Config] Configuration instance
|
|
41
|
+
# @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy
|
|
42
|
+
# @raise [ArgumentError] If required dependencies are missing
|
|
43
|
+
def initialize(connection:, config:, retry_strategy: nil)
|
|
44
|
+
raise ArgumentError, 'connection is required' unless connection
|
|
45
|
+
raise ArgumentError, 'config is required' unless config
|
|
46
|
+
|
|
47
|
+
@jts = connection
|
|
48
|
+
@config = config
|
|
49
49
|
@retry_strategy = retry_strategy || PublisherRetryStrategy.new
|
|
50
|
+
@envelope_builder = EventEnvelopeBuilder
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
# Publishes an event to NATS JetStream.
|
|
@@ -107,12 +108,17 @@ module JetstreamBridge
|
|
|
107
108
|
# logger.error "Failed to publish: #{result.error.message}"
|
|
108
109
|
# end
|
|
109
110
|
#
|
|
110
|
-
def publish(
|
|
111
|
+
def publish(event_type:, payload:, resource_type: nil, subject: nil, **)
|
|
111
112
|
ensure_destination_app_configured!
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
envelope = @envelope_builder.build(
|
|
115
|
+
event_type: event_type,
|
|
116
|
+
payload: payload,
|
|
117
|
+
resource_type: resource_type,
|
|
118
|
+
**
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
resolved_subject = subject || @config.source_subject
|
|
116
122
|
|
|
117
123
|
do_publish(resolved_subject, envelope)
|
|
118
124
|
rescue ArgumentError
|
|
@@ -128,6 +134,61 @@ module JetstreamBridge
|
|
|
128
134
|
)
|
|
129
135
|
end
|
|
130
136
|
|
|
137
|
+
# Publish a complete event envelope (advanced usage)
|
|
138
|
+
#
|
|
139
|
+
# Provides full control over the envelope structure. Use this when you need
|
|
140
|
+
# to manually construct the envelope or preserve all fields from an external source.
|
|
141
|
+
#
|
|
142
|
+
# @param envelope [Hash] Complete event envelope with all required fields
|
|
143
|
+
# @option envelope [String] 'event_id' Unique event identifier (required)
|
|
144
|
+
# @option envelope [Integer] 'schema_version' Schema version (required, typically 1)
|
|
145
|
+
# @option envelope [String] 'event_type' Event type (required)
|
|
146
|
+
# @option envelope [String] 'producer' Producer name (required)
|
|
147
|
+
# @option envelope [String] 'resource_type' Resource type (required)
|
|
148
|
+
# @option envelope [String] 'resource_id' Resource identifier (required)
|
|
149
|
+
# @option envelope [String] 'occurred_at' ISO8601 timestamp (required)
|
|
150
|
+
# @option envelope [String] 'trace_id' Trace identifier (required)
|
|
151
|
+
# @option envelope [Hash] 'payload' Event payload data (required)
|
|
152
|
+
# @param subject [String, nil] Optional NATS subject override
|
|
153
|
+
# @return [Models::PublishResult] Result object
|
|
154
|
+
#
|
|
155
|
+
# @raise [ArgumentError] If required envelope fields are missing
|
|
156
|
+
#
|
|
157
|
+
# @example Publishing a complete envelope
|
|
158
|
+
# envelope = {
|
|
159
|
+
# 'event_id' => SecureRandom.uuid,
|
|
160
|
+
# 'schema_version' => 1,
|
|
161
|
+
# 'event_type' => 'user.created',
|
|
162
|
+
# 'producer' => 'custom-producer',
|
|
163
|
+
# 'resource_type' => 'user',
|
|
164
|
+
# 'resource_id' => '123',
|
|
165
|
+
# 'occurred_at' => Time.now.utc.iso8601,
|
|
166
|
+
# 'trace_id' => 'trace-123',
|
|
167
|
+
# 'payload' => { id: 123, name: 'Alice' }
|
|
168
|
+
# }
|
|
169
|
+
#
|
|
170
|
+
# result = publisher.publish_envelope(envelope)
|
|
171
|
+
# puts "Published: #{result.event_id}"
|
|
172
|
+
def publish_envelope(envelope, subject: nil)
|
|
173
|
+
ensure_destination_app_configured!
|
|
174
|
+
validate_envelope!(envelope)
|
|
175
|
+
|
|
176
|
+
resolved_subject = subject || @config.source_subject
|
|
177
|
+
|
|
178
|
+
do_publish(resolved_subject, envelope)
|
|
179
|
+
rescue ArgumentError
|
|
180
|
+
# Re-raise validation errors for invalid envelope
|
|
181
|
+
raise
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
# Return failure result for publishing errors
|
|
184
|
+
Models::PublishResult.new(
|
|
185
|
+
success: false,
|
|
186
|
+
event_id: envelope&.[]('event_id') || 'unknown',
|
|
187
|
+
subject: resolved_subject || 'unknown',
|
|
188
|
+
error: e
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
131
192
|
# Internal publish method that routes to appropriate publish strategy.
|
|
132
193
|
#
|
|
133
194
|
# Routes to outbox-based publishing if use_outbox is enabled, otherwise
|
|
@@ -138,7 +199,7 @@ module JetstreamBridge
|
|
|
138
199
|
# @return [Models::PublishResult] Result object
|
|
139
200
|
# @api private
|
|
140
201
|
def do_publish(subject, envelope)
|
|
141
|
-
if
|
|
202
|
+
if @config.use_outbox
|
|
142
203
|
publish_via_outbox(subject, envelope)
|
|
143
204
|
else
|
|
144
205
|
with_retries { publish_to_nats(subject, envelope) }
|
|
@@ -147,52 +208,20 @@ module JetstreamBridge
|
|
|
147
208
|
|
|
148
209
|
private
|
|
149
210
|
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
build_from_structured_params(params)
|
|
155
|
-
elsif keyword_or_hash_params?(params)
|
|
156
|
-
build_from_keyword_or_hash(params)
|
|
157
|
-
else
|
|
158
|
-
raise ArgumentError, 'Either provide (resource_type:, event_type:, payload:) or an event hash'
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def structured_params?(params)
|
|
163
|
-
params[:resource_type] && params[:event_type] && params[:payload]
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def keyword_or_hash_params?(params)
|
|
167
|
-
params[:event_type] || params[:payload] || params[:event_or_hash].is_a?(Hash)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def build_from_structured_params(params)
|
|
171
|
-
envelope = build_envelope(params[:resource_type], params[:event_type], params[:payload], params[:options])
|
|
172
|
-
resolved_subject = params[:subject] || JetstreamBridge.config.source_subject
|
|
173
|
-
[envelope, resolved_subject]
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def build_from_keyword_or_hash(params)
|
|
177
|
-
envelope = if params[:event_or_hash].is_a?(Hash)
|
|
178
|
-
normalize_envelope(params[:event_or_hash], params[:options])
|
|
179
|
-
else
|
|
180
|
-
build_from_keywords(params[:event_type], params[:payload], params[:options])
|
|
181
|
-
end
|
|
211
|
+
# Validate envelope has all required fields
|
|
212
|
+
def validate_envelope!(envelope)
|
|
213
|
+
required_fields = %w[event_id schema_version event_type producer resource_type
|
|
214
|
+
resource_id occurred_at trace_id payload]
|
|
182
215
|
|
|
183
|
-
|
|
184
|
-
[envelope, resolved_subject]
|
|
185
|
-
end
|
|
216
|
+
missing = required_fields.select { |field| envelope[field].nil? || envelope[field].to_s.strip.empty? }
|
|
186
217
|
|
|
187
|
-
|
|
188
|
-
raise ArgumentError, 'event_type is required' unless event_type
|
|
189
|
-
raise ArgumentError, 'payload is required' unless payload
|
|
218
|
+
return if missing.empty?
|
|
190
219
|
|
|
191
|
-
|
|
220
|
+
raise ArgumentError, "Envelope missing required fields: #{missing.join(', ')}"
|
|
192
221
|
end
|
|
193
222
|
|
|
194
223
|
def ensure_destination_app_configured!
|
|
195
|
-
return unless
|
|
224
|
+
return unless @config.destination_app.to_s.empty?
|
|
196
225
|
|
|
197
226
|
raise ArgumentError, 'destination_app must be configured'
|
|
198
227
|
end
|
|
@@ -231,7 +260,7 @@ module JetstreamBridge
|
|
|
231
260
|
|
|
232
261
|
# ---- Outbox path ----
|
|
233
262
|
def publish_via_outbox(subject, envelope)
|
|
234
|
-
klass = ModelUtils.constantize(
|
|
263
|
+
klass = ModelUtils.constantize(@config.outbox_model)
|
|
235
264
|
|
|
236
265
|
unless ModelUtils.ar_class?(klass)
|
|
237
266
|
Logging.warn(
|
|
@@ -288,64 +317,5 @@ module JetstreamBridge
|
|
|
288
317
|
)
|
|
289
318
|
raise
|
|
290
319
|
end
|
|
291
|
-
|
|
292
|
-
def build_envelope(resource_type, event_type, payload, options = {})
|
|
293
|
-
{
|
|
294
|
-
'event_id' => options[:event_id] || SecureRandom.uuid,
|
|
295
|
-
'schema_version' => 1,
|
|
296
|
-
'event_type' => event_type,
|
|
297
|
-
'producer' => JetstreamBridge.config.app_name,
|
|
298
|
-
'resource_id' => extract_resource_id(payload),
|
|
299
|
-
'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
|
|
300
|
-
'trace_id' => options[:trace_id] || SecureRandom.hex(8),
|
|
301
|
-
'resource_type' => resource_type,
|
|
302
|
-
'payload' => payload
|
|
303
|
-
}
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Normalize a hash to match envelope structure, allowing partial envelopes
|
|
307
|
-
def normalize_envelope(hash, options = {})
|
|
308
|
-
hash = hash.transform_keys(&:to_s)
|
|
309
|
-
infer_resource_type_if_needed!(hash)
|
|
310
|
-
|
|
311
|
-
{
|
|
312
|
-
'event_id' => envelope_event_id(hash, options),
|
|
313
|
-
'schema_version' => hash['schema_version'] || 1,
|
|
314
|
-
'event_type' => hash['event_type'] || raise(ArgumentError, 'event_type is required'),
|
|
315
|
-
'producer' => hash['producer'] || JetstreamBridge.config.app_name,
|
|
316
|
-
'resource_id' => hash['resource_id'] || extract_resource_id(hash['payload']),
|
|
317
|
-
'occurred_at' => envelope_occurred_at(hash, options),
|
|
318
|
-
'trace_id' => envelope_trace_id(hash, options),
|
|
319
|
-
'resource_type' => hash['resource_type'] || 'event',
|
|
320
|
-
'payload' => hash['payload'] || raise(ArgumentError, 'payload is required')
|
|
321
|
-
}
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def infer_resource_type_if_needed!(hash)
|
|
325
|
-
return unless hash['event_type'] && hash['payload'] && !hash['resource_type']
|
|
326
|
-
|
|
327
|
-
# Try to infer from dot notation (e.g., 'user.created' -> 'user')
|
|
328
|
-
parts = hash['event_type'].split('.')
|
|
329
|
-
hash['resource_type'] = parts[0] if parts.size > 1
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
def envelope_event_id(hash, options)
|
|
333
|
-
hash['event_id'] || options[:event_id] || SecureRandom.uuid
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
def envelope_occurred_at(hash, options)
|
|
337
|
-
hash['occurred_at'] || (options[:occurred_at] || Time.now.utc).iso8601
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
def envelope_trace_id(hash, options)
|
|
341
|
-
hash['trace_id'] || options[:trace_id] || SecureRandom.hex(8)
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
def extract_resource_id(payload)
|
|
345
|
-
return '' unless payload
|
|
346
|
-
|
|
347
|
-
payload = payload.transform_keys(&:to_s) if payload.respond_to?(:transform_keys)
|
|
348
|
-
(payload['id'] || payload[:id] || payload['resource_id'] || payload[:resource_id]).to_s
|
|
349
|
-
end
|
|
350
320
|
end
|
|
351
321
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../core/model_codec_setup'
|
|
4
4
|
require_relative '../core/logging'
|
|
5
|
-
require_relative '../core/connection'
|
|
6
5
|
|
|
7
6
|
module JetstreamBridge
|
|
8
7
|
module Rails
|
|
@@ -38,8 +37,7 @@ module JetstreamBridge
|
|
|
38
37
|
return
|
|
39
38
|
end
|
|
40
39
|
|
|
41
|
-
JetstreamBridge.
|
|
42
|
-
JetstreamBridge.startup!
|
|
40
|
+
JetstreamBridge.connect!
|
|
43
41
|
log_started!
|
|
44
42
|
log_development_connection_details! if rails_development?
|
|
45
43
|
register_shutdown_hook!
|
|
@@ -105,7 +103,12 @@ module JetstreamBridge
|
|
|
105
103
|
end
|
|
106
104
|
|
|
107
105
|
def log_development_connection_details!
|
|
108
|
-
|
|
106
|
+
health = begin
|
|
107
|
+
JetstreamBridge.health
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
conn_state = health&.dig(:connection, :state) || 'unknown'
|
|
109
112
|
active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
|
|
110
113
|
active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
|
|
111
114
|
active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
|
|
@@ -116,7 +119,7 @@ module JetstreamBridge
|
|
|
116
119
|
def register_shutdown_hook!
|
|
117
120
|
return if @shutdown_hook_registered
|
|
118
121
|
|
|
119
|
-
at_exit { JetstreamBridge.
|
|
122
|
+
at_exit { JetstreamBridge.disconnect! }
|
|
120
123
|
@shutdown_hook_registered = true
|
|
121
124
|
end
|
|
122
125
|
|
|
@@ -7,7 +7,7 @@ module JetstreamBridge
|
|
|
7
7
|
#
|
|
8
8
|
# This Railtie integrates JetStream Bridge with the Rails application lifecycle:
|
|
9
9
|
# - Configuration: Logger is configured early in the Rails boot process
|
|
10
|
-
# - Startup: Connection is established after user initializers load (
|
|
10
|
+
# - Startup: Connection is established after user initializers load (connect!)
|
|
11
11
|
# - Shutdown: Connection is closed when Rails shuts down (at_exit hook)
|
|
12
12
|
# - Restart: Puma/Unicorn workers get fresh connections on fork
|
|
13
13
|
#
|
|
@@ -33,10 +33,11 @@ module JetstreamBridge
|
|
|
33
33
|
console do
|
|
34
34
|
::Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
|
|
35
35
|
::Rails.logger.info '[JetStream Bridge] Console helpers available:'
|
|
36
|
-
::Rails.logger.info ' JetstreamBridge.
|
|
36
|
+
::Rails.logger.info ' JetstreamBridge.health - Check connection status'
|
|
37
|
+
::Rails.logger.info ' JetstreamBridge.healthy? - Simple boolean health check'
|
|
37
38
|
::Rails.logger.info ' JetstreamBridge.stream_info - View stream details'
|
|
38
39
|
::Rails.logger.info ' JetstreamBridge.connected? - Check if connected'
|
|
39
|
-
::Rails.logger.info ' JetstreamBridge.
|
|
40
|
+
::Rails.logger.info ' JetstreamBridge.disconnect! - Gracefully disconnect'
|
|
40
41
|
::Rails.logger.info ' JetstreamBridge.reconnect! - Reconnect (useful after configuration changes)'
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -62,7 +62,6 @@ namespace :jetstream_bridge do
|
|
|
62
62
|
JetstreamBridge.config.validate!
|
|
63
63
|
puts '✓ Configuration is valid'
|
|
64
64
|
puts "\nCurrent settings:"
|
|
65
|
-
puts " Environment: #{JetstreamBridge.config.env}"
|
|
66
65
|
puts " App Name: #{JetstreamBridge.config.app_name}"
|
|
67
66
|
puts " Destination: #{JetstreamBridge.config.destination_app}"
|
|
68
67
|
puts " Stream: #{JetstreamBridge.config.stream_name}"
|
|
@@ -6,8 +6,13 @@ require_relative 'stream'
|
|
|
6
6
|
|
|
7
7
|
module JetstreamBridge
|
|
8
8
|
class Topology
|
|
9
|
-
def self.ensure!(jts)
|
|
9
|
+
def self.ensure!(jts, force: false)
|
|
10
10
|
cfg = JetstreamBridge.config
|
|
11
|
+
if cfg.disable_js_api && !force
|
|
12
|
+
Logging.info('Topology ensure skipped (JS API disabled).', tag: 'JetstreamBridge::Topology')
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
subjects = [cfg.source_subject, cfg.destination_subject]
|
|
12
17
|
subjects << cfg.dlq_subject if cfg.use_dlq
|
|
13
18
|
Stream.ensure!(jts, cfg.stream_name, subjects)
|