jetstream_bridge 4.4.1 → 4.5.1
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 +6 -0
- data/README.md +3 -17
- data/docs/GETTING_STARTED.md +2 -2
- data/docs/PRODUCTION.md +28 -10
- data/docs/RESTRICTED_PERMISSIONS.md +399 -0
- data/docs/TESTING.md +5 -5
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +4 -4
- data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +1 -1
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +2 -3
- 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 +5 -6
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +72 -79
- data/lib/jetstream_bridge/core/bridge_helpers.rb +37 -19
- data/lib/jetstream_bridge/core/config.rb +20 -42
- data/lib/jetstream_bridge/core/connection.rb +60 -16
- data/lib/jetstream_bridge/core/connection_factory.rb +2 -7
- data/lib/jetstream_bridge/core/debug_helper.rb +0 -1
- data/lib/jetstream_bridge/models/subject.rb +15 -23
- data/lib/jetstream_bridge/provisioner.rb +67 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +8 -9
- data/lib/jetstream_bridge/tasks/install.rake +17 -2
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +89 -37
- metadata +5 -3
|
@@ -58,8 +58,8 @@ module JetstreamBridge
|
|
|
58
58
|
# Safe to call from multiple threads - uses class-level mutex for synchronization.
|
|
59
59
|
#
|
|
60
60
|
# @return [NATS::JetStream::JS] JetStream context
|
|
61
|
-
def connect!
|
|
62
|
-
@@connection_lock.synchronize { instance.connect! }
|
|
61
|
+
def connect!(verify_js: nil)
|
|
62
|
+
@@connection_lock.synchronize { instance.connect!(verify_js: verify_js) }
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# Optional accessors if callers need raw handles
|
|
@@ -73,7 +73,8 @@ module JetstreamBridge
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# Idempotent: returns an existing, healthy JetStream context or establishes one.
|
|
76
|
-
def connect!
|
|
76
|
+
def connect!(verify_js: nil)
|
|
77
|
+
verify_js = config_auto_provision if verify_js.nil?
|
|
77
78
|
# Check if already connected without acquiring mutex (for performance)
|
|
78
79
|
return @jts if @jts && @nc&.connected?
|
|
79
80
|
|
|
@@ -81,7 +82,7 @@ module JetstreamBridge
|
|
|
81
82
|
raise 'No NATS URLs configured' if servers.empty?
|
|
82
83
|
|
|
83
84
|
@state = State::CONNECTING
|
|
84
|
-
establish_connection_with_retry(servers)
|
|
85
|
+
establish_connection_with_retry(servers, verify_js: verify_js)
|
|
85
86
|
|
|
86
87
|
Logging.info(
|
|
87
88
|
"Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
|
|
@@ -89,9 +90,6 @@ module JetstreamBridge
|
|
|
89
90
|
tag: 'JetstreamBridge::Connection'
|
|
90
91
|
)
|
|
91
92
|
|
|
92
|
-
# Ensure topology (streams, subjects, overlap guard, etc.)
|
|
93
|
-
Topology.ensure!(@jts)
|
|
94
|
-
|
|
95
93
|
@connected_at = Time.now.utc
|
|
96
94
|
@state = State::CONNECTED
|
|
97
95
|
@jts
|
|
@@ -124,8 +122,8 @@ module JetstreamBridge
|
|
|
124
122
|
now = Time.now.to_i
|
|
125
123
|
return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
|
|
126
124
|
|
|
127
|
-
# Perform actual health check
|
|
128
|
-
@cached_health_status = jetstream_healthy?
|
|
125
|
+
# Perform actual health check (management APIs optional)
|
|
126
|
+
@cached_health_status = jetstream_healthy?(verify_js: config_auto_provision)
|
|
129
127
|
@last_health_check = now
|
|
130
128
|
@cached_health_status
|
|
131
129
|
end
|
|
@@ -151,7 +149,10 @@ module JetstreamBridge
|
|
|
151
149
|
|
|
152
150
|
private
|
|
153
151
|
|
|
154
|
-
def jetstream_healthy?
|
|
152
|
+
def jetstream_healthy?(verify_js:)
|
|
153
|
+
# Lightweight health when management APIs are disabled
|
|
154
|
+
return ping_only_health unless verify_js
|
|
155
|
+
|
|
155
156
|
# Verify JetStream responds to simple API call
|
|
156
157
|
@jts.account_info
|
|
157
158
|
true
|
|
@@ -163,6 +164,20 @@ module JetstreamBridge
|
|
|
163
164
|
false
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
def ping_only_health
|
|
168
|
+
return false unless @nc&.connected?
|
|
169
|
+
|
|
170
|
+
# Flush acts as a ping/pong round-trip without hitting JetStream management subjects
|
|
171
|
+
@nc.flush(0.5)
|
|
172
|
+
true
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
Logging.warn(
|
|
175
|
+
"NATS connectivity check failed: #{e.class} #{e.message}",
|
|
176
|
+
tag: 'JetstreamBridge::Connection'
|
|
177
|
+
)
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
166
181
|
def nats_servers
|
|
167
182
|
servers = JetstreamBridge.config.nats_urls
|
|
168
183
|
.to_s
|
|
@@ -174,14 +189,14 @@ module JetstreamBridge
|
|
|
174
189
|
servers
|
|
175
190
|
end
|
|
176
191
|
|
|
177
|
-
def establish_connection_with_retry(servers)
|
|
192
|
+
def establish_connection_with_retry(servers, verify_js:)
|
|
178
193
|
attempts = 0
|
|
179
194
|
max_attempts = JetstreamBridge.config.connect_retry_attempts
|
|
180
195
|
retry_delay = JetstreamBridge.config.connect_retry_delay
|
|
181
196
|
|
|
182
197
|
begin
|
|
183
198
|
attempts += 1
|
|
184
|
-
establish_connection(servers)
|
|
199
|
+
establish_connection(servers, verify_js: verify_js)
|
|
185
200
|
rescue ConnectionError => e
|
|
186
201
|
if attempts < max_attempts
|
|
187
202
|
delay = retry_delay * attempts
|
|
@@ -203,7 +218,7 @@ module JetstreamBridge
|
|
|
203
218
|
end
|
|
204
219
|
end
|
|
205
220
|
|
|
206
|
-
def establish_connection(servers)
|
|
221
|
+
def establish_connection(servers, verify_js:)
|
|
207
222
|
# Use mock NATS client if explicitly enabled for testing
|
|
208
223
|
# This allows test helpers to inject a mock without affecting normal operation
|
|
209
224
|
@nc = if defined?(JetstreamBridge::TestHelpers) &&
|
|
@@ -257,7 +272,22 @@ module JetstreamBridge
|
|
|
257
272
|
@jts = @nc.jetstream
|
|
258
273
|
|
|
259
274
|
# Verify JetStream is available
|
|
260
|
-
|
|
275
|
+
if verify_js
|
|
276
|
+
verify_jetstream!
|
|
277
|
+
if config_auto_provision
|
|
278
|
+
Topology.ensure!(@jts)
|
|
279
|
+
Logging.info(
|
|
280
|
+
'Topology ensured after connection (auto_provision=true).',
|
|
281
|
+
tag: 'JetstreamBridge::Connection'
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
else
|
|
285
|
+
Logging.info(
|
|
286
|
+
'Skipping JetStream account_info verification (auto_provision=false). ' \
|
|
287
|
+
'Assuming JetStream is enabled.',
|
|
288
|
+
tag: 'JetstreamBridge::Connection'
|
|
289
|
+
)
|
|
290
|
+
end
|
|
261
291
|
|
|
262
292
|
# Ensure JetStream responds to #nc
|
|
263
293
|
return if @jts.respond_to?(:nc)
|
|
@@ -400,8 +430,15 @@ module JetstreamBridge
|
|
|
400
430
|
nc_ref = @nc
|
|
401
431
|
@jts.define_singleton_method(:nc) { nc_ref } unless @jts.respond_to?(:nc)
|
|
402
432
|
|
|
403
|
-
# Re-ensure topology after reconnect
|
|
404
|
-
|
|
433
|
+
# Re-ensure topology after reconnect when allowed
|
|
434
|
+
if config_auto_provision
|
|
435
|
+
Topology.ensure!(@jts)
|
|
436
|
+
else
|
|
437
|
+
Logging.info(
|
|
438
|
+
'Skipping topology provisioning after reconnect (auto_provision=false).',
|
|
439
|
+
tag: 'JetstreamBridge::Connection'
|
|
440
|
+
)
|
|
441
|
+
end
|
|
405
442
|
|
|
406
443
|
# Invalidate health check cache on successful reconnect
|
|
407
444
|
@cached_health_status = nil
|
|
@@ -460,5 +497,12 @@ module JetstreamBridge
|
|
|
460
497
|
@last_health_check = nil
|
|
461
498
|
@connected_at = nil
|
|
462
499
|
end
|
|
500
|
+
|
|
501
|
+
def config_auto_provision
|
|
502
|
+
cfg = JetstreamBridge.config
|
|
503
|
+
cfg.respond_to?(:auto_provision) ? cfg.auto_provision : true
|
|
504
|
+
rescue StandardError
|
|
505
|
+
true
|
|
506
|
+
end
|
|
463
507
|
end
|
|
464
508
|
end
|
|
@@ -9,12 +9,7 @@ module JetstreamBridge
|
|
|
9
9
|
class ConnectionFactory
|
|
10
10
|
# Connection options builder
|
|
11
11
|
class ConnectionOptions
|
|
12
|
-
DEFAULT_OPTS =
|
|
13
|
-
reconnect: true,
|
|
14
|
-
reconnect_time_wait: 2,
|
|
15
|
-
max_reconnect_attempts: 10,
|
|
16
|
-
connect_timeout: 5
|
|
17
|
-
}.freeze
|
|
12
|
+
DEFAULT_OPTS = JetstreamBridge::Connection::DEFAULT_CONN_OPTS
|
|
18
13
|
|
|
19
14
|
attr_accessor :servers, :reconnect, :reconnect_time_wait,
|
|
20
15
|
:max_reconnect_attempts, :connect_timeout,
|
|
@@ -73,7 +68,7 @@ module JetstreamBridge
|
|
|
73
68
|
|
|
74
69
|
ConnectionOptions.new(
|
|
75
70
|
servers: servers,
|
|
76
|
-
name:
|
|
71
|
+
name: config.app_name
|
|
77
72
|
)
|
|
78
73
|
end
|
|
79
74
|
|
|
@@ -5,12 +5,11 @@ module JetstreamBridge
|
|
|
5
5
|
# Value object representing a NATS subject
|
|
6
6
|
#
|
|
7
7
|
# @example Creating a subject
|
|
8
|
-
# subject = Subject.source(
|
|
9
|
-
# subject.to_s # => "
|
|
8
|
+
# subject = Subject.source(app_name: "api", dest: "worker")
|
|
9
|
+
# subject.to_s # => "api.sync.worker"
|
|
10
10
|
#
|
|
11
11
|
# @example Parsing a subject string
|
|
12
|
-
# subject = Subject.parse("
|
|
13
|
-
# subject.env # => "production"
|
|
12
|
+
# subject = Subject.parse("api.sync.worker")
|
|
14
13
|
# subject.source_app # => "api"
|
|
15
14
|
# subject.dest_app # => "worker"
|
|
16
15
|
class Subject
|
|
@@ -31,16 +30,16 @@ module JetstreamBridge
|
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
# Factory methods
|
|
34
|
-
def self.source(
|
|
35
|
-
new("#{
|
|
33
|
+
def self.source(app_name:, dest:)
|
|
34
|
+
new("#{app_name}.sync.#{dest}")
|
|
36
35
|
end
|
|
37
36
|
|
|
38
|
-
def self.destination(
|
|
39
|
-
new("#{
|
|
37
|
+
def self.destination(source:, app_name:)
|
|
38
|
+
new("#{source}.sync.#{app_name}")
|
|
40
39
|
end
|
|
41
40
|
|
|
42
|
-
def self.dlq(
|
|
43
|
-
new("#{
|
|
41
|
+
def self.dlq(app_name:)
|
|
42
|
+
new("#{app_name}.sync.dlq")
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
# Parse a subject string into a Subject object with metadata
|
|
@@ -51,37 +50,30 @@ module JetstreamBridge
|
|
|
51
50
|
new(string)
|
|
52
51
|
end
|
|
53
52
|
|
|
54
|
-
# Get environment from subject (first token)
|
|
55
|
-
#
|
|
56
|
-
# @return [String, nil] Environment
|
|
57
|
-
def env
|
|
58
|
-
@tokens[0]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
53
|
# Get source application from subject
|
|
62
54
|
#
|
|
63
|
-
# For regular subjects: {
|
|
64
|
-
# For DLQ subjects: {
|
|
55
|
+
# For regular subjects: {source_app}.sync.{dest}
|
|
56
|
+
# For DLQ subjects: {app_name}.sync.dlq
|
|
65
57
|
#
|
|
66
58
|
# @return [String, nil] Source application
|
|
67
59
|
def source_app
|
|
68
|
-
@tokens[
|
|
60
|
+
@tokens[0]
|
|
69
61
|
end
|
|
70
62
|
|
|
71
63
|
# Get destination application from subject
|
|
72
64
|
#
|
|
73
65
|
# @return [String, nil] Destination application
|
|
74
66
|
def dest_app
|
|
75
|
-
@tokens[
|
|
67
|
+
@tokens[2]
|
|
76
68
|
end
|
|
77
69
|
|
|
78
70
|
# Check if this is a DLQ subject
|
|
79
71
|
#
|
|
80
|
-
# DLQ subjects follow the pattern: {
|
|
72
|
+
# DLQ subjects follow the pattern: {app}.sync.dlq
|
|
81
73
|
#
|
|
82
74
|
# @return [Boolean] True if this is a DLQ subject
|
|
83
75
|
def dlq?
|
|
84
|
-
@tokens.length ==
|
|
76
|
+
@tokens.length == 3 && @tokens[1] == 'sync' && @tokens[2] == 'dlq'
|
|
85
77
|
end
|
|
86
78
|
|
|
87
79
|
# Check if this subject matches a pattern
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'topology/topology'
|
|
4
|
+
require_relative 'consumer/subscription_manager'
|
|
5
|
+
require_relative 'core/logging'
|
|
6
|
+
require_relative 'core/config'
|
|
7
|
+
require_relative 'core/connection'
|
|
8
|
+
|
|
9
|
+
module JetstreamBridge
|
|
10
|
+
# Dedicated provisioning orchestrator to keep connection concerns separate.
|
|
11
|
+
#
|
|
12
|
+
# Handles creating/updating stream topology and consumers. Can be used at
|
|
13
|
+
# deploy-time with admin credentials or during runtime when auto_provision
|
|
14
|
+
# is enabled.
|
|
15
|
+
class Provisioner
|
|
16
|
+
def initialize(config: JetstreamBridge.config)
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Ensure stream (and optionally consumer) exist with desired config.
|
|
21
|
+
#
|
|
22
|
+
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
23
|
+
# @param ensure_consumer [Boolean] Whether to create/align the consumer too
|
|
24
|
+
# @return [Object] JetStream context used for provisioning
|
|
25
|
+
def ensure!(jts: nil, ensure_consumer: true)
|
|
26
|
+
js = jts || Connection.connect!(verify_js: true)
|
|
27
|
+
|
|
28
|
+
ensure_stream!(js)
|
|
29
|
+
ensure_consumer!(js) if ensure_consumer
|
|
30
|
+
|
|
31
|
+
Logging.info(
|
|
32
|
+
"Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if ensure_consumer}",
|
|
33
|
+
tag: 'JetstreamBridge::Provisioner'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
js
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Ensure stream only.
|
|
40
|
+
#
|
|
41
|
+
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
42
|
+
# @return [Object] JetStream context used
|
|
43
|
+
def ensure_stream!(jts: nil)
|
|
44
|
+
js = jts || Connection.connect!(verify_js: true)
|
|
45
|
+
Topology.ensure!(js)
|
|
46
|
+
Logging.info(
|
|
47
|
+
"Stream ensured: #{@config.stream_name}",
|
|
48
|
+
tag: 'JetstreamBridge::Provisioner'
|
|
49
|
+
)
|
|
50
|
+
js
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Ensure durable consumer only.
|
|
54
|
+
#
|
|
55
|
+
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
56
|
+
# @return [Object] JetStream context used
|
|
57
|
+
def ensure_consumer!(jts: nil)
|
|
58
|
+
js = jts || Connection.connect!(verify_js: true)
|
|
59
|
+
SubscriptionManager.new(js, @config.durable_name, @config).ensure_consumer!(force: true)
|
|
60
|
+
Logging.info(
|
|
61
|
+
"Consumer ensured: #{@config.durable_name}",
|
|
62
|
+
tag: 'JetstreamBridge::Provisioner'
|
|
63
|
+
)
|
|
64
|
+
js
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -13,7 +13,7 @@ require_relative 'outbox_repository'
|
|
|
13
13
|
module JetstreamBridge
|
|
14
14
|
# Publishes events to NATS JetStream with reliability features.
|
|
15
15
|
#
|
|
16
|
-
# Publishes events to "{
|
|
16
|
+
# Publishes events to "{app}.sync.{dest}" subject pattern.
|
|
17
17
|
# Supports optional transactional outbox pattern for guaranteed delivery.
|
|
18
18
|
#
|
|
19
19
|
# @example Basic publishing
|
|
@@ -115,8 +115,8 @@ module JetstreamBridge
|
|
|
115
115
|
envelope, resolved_subject = route_publish_params(params)
|
|
116
116
|
|
|
117
117
|
do_publish(resolved_subject, envelope)
|
|
118
|
-
rescue ArgumentError
|
|
119
|
-
# Re-raise validation errors
|
|
118
|
+
rescue ArgumentError, JetstreamBridge::ConfigurationError
|
|
119
|
+
# Re-raise validation/configuration errors (e.g., missing destination_app)
|
|
120
120
|
raise
|
|
121
121
|
rescue StandardError => e
|
|
122
122
|
# Return failure result for publishing errors
|
|
@@ -191,12 +191,6 @@ module JetstreamBridge
|
|
|
191
191
|
normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
-
def ensure_destination_app_configured!
|
|
195
|
-
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
|
196
|
-
|
|
197
|
-
raise ArgumentError, 'destination_app must be configured'
|
|
198
|
-
end
|
|
199
|
-
|
|
200
194
|
def publish_to_nats(subject, envelope)
|
|
201
195
|
headers = { 'nats-msg-id' => envelope['event_id'] }
|
|
202
196
|
|
|
@@ -347,5 +341,10 @@ module JetstreamBridge
|
|
|
347
341
|
payload = payload.transform_keys(&:to_s) if payload.respond_to?(:transform_keys)
|
|
348
342
|
(payload['id'] || payload[:id] || payload['resource_id'] || payload[:resource_id]).to_s
|
|
349
343
|
end
|
|
344
|
+
|
|
345
|
+
def ensure_destination_app_configured!
|
|
346
|
+
# Leverage subject builder to enforce required components and surface consistent error messaging.
|
|
347
|
+
JetstreamBridge.config.source_subject
|
|
348
|
+
end
|
|
350
349
|
end
|
|
351
350
|
end
|
|
@@ -36,7 +36,7 @@ namespace :jetstream_bridge do
|
|
|
36
36
|
|
|
37
37
|
if health[:config]
|
|
38
38
|
puts "\nConfiguration:"
|
|
39
|
-
puts "
|
|
39
|
+
puts " Stream: #{health[:config][:stream_name]}"
|
|
40
40
|
puts " App Name: #{health[:config][:app_name]}"
|
|
41
41
|
puts " Destination: #{health[:config][:destination_app] || 'NOT SET'}"
|
|
42
42
|
puts " Outbox: #{health[:config][:use_outbox] ? 'Enabled' : 'Disabled'}"
|
|
@@ -62,7 +62,7 @@ namespace :jetstream_bridge do
|
|
|
62
62
|
JetstreamBridge.config.validate!
|
|
63
63
|
puts '✓ Configuration is valid'
|
|
64
64
|
puts "\nCurrent settings:"
|
|
65
|
-
puts "
|
|
65
|
+
puts " Stream: #{JetstreamBridge.config.stream_name}"
|
|
66
66
|
puts " App Name: #{JetstreamBridge.config.app_name}"
|
|
67
67
|
puts " Destination: #{JetstreamBridge.config.destination_app}"
|
|
68
68
|
puts " Stream: #{JetstreamBridge.config.stream_name}"
|
|
@@ -80,6 +80,21 @@ namespace :jetstream_bridge do
|
|
|
80
80
|
JetstreamBridge::DebugHelper.debug_info
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
desc 'Provision stream and consumer (requires JetStream admin permissions)'
|
|
84
|
+
task provision: :environment do
|
|
85
|
+
puts '[jetstream_bridge] Provisioning JetStream stream and consumer...'
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
provisioner = JetstreamBridge::Provisioner.new
|
|
89
|
+
provisioner.ensure!(ensure_consumer: true)
|
|
90
|
+
puts "✓ Provisioned stream=#{JetstreamBridge.config.stream_name} consumer=#{JetstreamBridge.config.durable_name}"
|
|
91
|
+
exit 0
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
puts "✗ Provisioning failed: #{e.message}"
|
|
94
|
+
exit 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
83
98
|
desc 'Test connection to NATS'
|
|
84
99
|
task test_connection: :environment do
|
|
85
100
|
puts '[jetstream_bridge] Testing NATS connection...'
|
data/lib/jetstream_bridge.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative 'jetstream_bridge/consumer/consumer'
|
|
|
8
8
|
require_relative 'jetstream_bridge/consumer/middleware'
|
|
9
9
|
require_relative 'jetstream_bridge/models/publish_result'
|
|
10
10
|
require_relative 'jetstream_bridge/models/event'
|
|
11
|
+
require_relative 'jetstream_bridge/provisioner'
|
|
11
12
|
|
|
12
13
|
# Rails-specific entry point (lifecycle helpers + Railtie)
|
|
13
14
|
require_relative 'jetstream_bridge/rails' if defined?(Rails::Railtie)
|
|
@@ -34,7 +35,6 @@ require_relative 'jetstream_bridge/models/outbox_event'
|
|
|
34
35
|
# # Configure
|
|
35
36
|
# JetstreamBridge.configure do |config|
|
|
36
37
|
# config.nats_urls = "nats://localhost:4222"
|
|
37
|
-
# config.env = "development"
|
|
38
38
|
# config.app_name = "my_app"
|
|
39
39
|
# config.destination_app = "other_app"
|
|
40
40
|
# config.use_outbox = true
|
|
@@ -87,7 +87,7 @@ module JetstreamBridge
|
|
|
87
87
|
# JetstreamBridge.startup! # Explicitly start connection
|
|
88
88
|
#
|
|
89
89
|
# @example With hash overrides
|
|
90
|
-
# JetstreamBridge.configure(
|
|
90
|
+
# JetstreamBridge.configure(app_name: 'my_app')
|
|
91
91
|
#
|
|
92
92
|
# @param overrides [Hash] Configuration key-value pairs to set
|
|
93
93
|
# @yield [Config] Configuration object for block-based configuration
|
|
@@ -139,6 +139,7 @@ module JetstreamBridge
|
|
|
139
139
|
def startup!
|
|
140
140
|
return if @connection_initialized
|
|
141
141
|
|
|
142
|
+
config.validate!
|
|
142
143
|
connect_and_ensure_stream!
|
|
143
144
|
@connection_initialized = true
|
|
144
145
|
Logging.info('JetStream Bridge started successfully', tag: 'JetstreamBridge')
|
|
@@ -200,12 +201,34 @@ module JetstreamBridge
|
|
|
200
201
|
#
|
|
201
202
|
# @return [Object] JetStream context
|
|
202
203
|
def connect_and_ensure_stream!
|
|
203
|
-
|
|
204
|
+
config.validate!
|
|
205
|
+
provision = config.auto_provision
|
|
206
|
+
Connection.connect!(verify_js: provision)
|
|
204
207
|
jts = Connection.jetstream
|
|
205
|
-
|
|
208
|
+
raise ConnectionNotEstablishedError, 'JetStream connection not available' unless jts
|
|
209
|
+
|
|
210
|
+
if provision
|
|
211
|
+
Provisioner.new(config: config).ensure_stream!(jts: jts)
|
|
212
|
+
else
|
|
213
|
+
Logging.info(
|
|
214
|
+
'auto_provision=false: skipping stream provisioning and JetStream account_info. ' \
|
|
215
|
+
'Run `bundle exec rake jetstream_bridge:provision` with admin credentials to create/update topology.',
|
|
216
|
+
tag: 'JetstreamBridge'
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
206
220
|
jts
|
|
207
221
|
end
|
|
208
222
|
|
|
223
|
+
# Provision stream/consumer using management credentials (out of band from runtime).
|
|
224
|
+
#
|
|
225
|
+
# @param ensure_consumer [Boolean] Whether to create/align the consumer along with the stream.
|
|
226
|
+
# @return [Object] JetStream context
|
|
227
|
+
def provision!(ensure_consumer: true)
|
|
228
|
+
config.validate!
|
|
229
|
+
Provisioner.new(config: config).ensure!(ensure_consumer: ensure_consumer)
|
|
230
|
+
end
|
|
231
|
+
|
|
209
232
|
# Backwards-compatible alias for the previous method name
|
|
210
233
|
def ensure_topology!
|
|
211
234
|
connect_and_ensure_stream!
|
|
@@ -229,46 +252,20 @@ module JetstreamBridge
|
|
|
229
252
|
enforce_health_check_rate_limit! if skip_cache
|
|
230
253
|
|
|
231
254
|
start_time = Time.now
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
connected = conn_instance.connected?(skip_cache: skip_cache)
|
|
237
|
-
connected_at = conn_instance.connected_at
|
|
238
|
-
connection_state = conn_instance.state
|
|
239
|
-
last_error = conn_instance.last_reconnect_error
|
|
240
|
-
last_error_at = conn_instance.last_reconnect_error_at
|
|
241
|
-
|
|
242
|
-
# Active check: queries actual stream from NATS server
|
|
243
|
-
stream_info = connected ? fetch_stream_info : { exists: false, name: config.stream_name }
|
|
244
|
-
|
|
245
|
-
# Active check: measure NATS round-trip time
|
|
246
|
-
rtt_ms = measure_nats_rtt if connected
|
|
247
|
-
|
|
248
|
-
health_check_duration_ms = ((Time.now - start_time) * 1000).round(2)
|
|
255
|
+
conn_status = connection_snapshot(Connection.instance, skip_cache: skip_cache)
|
|
256
|
+
stream_info = stream_status(conn_status[:connected])
|
|
257
|
+
rtt_ms = measure_nats_rtt if conn_status[:connected]
|
|
258
|
+
health_check_duration_ms = elapsed_ms(start_time)
|
|
249
259
|
|
|
250
260
|
{
|
|
251
|
-
healthy:
|
|
252
|
-
connection:
|
|
253
|
-
state: connection_state,
|
|
254
|
-
connected: connected,
|
|
255
|
-
connected_at: connected_at&.iso8601,
|
|
256
|
-
last_error: last_error&.message,
|
|
257
|
-
last_error_at: last_error_at&.iso8601
|
|
258
|
-
},
|
|
261
|
+
healthy: health_flag(conn_status[:connected], stream_info),
|
|
262
|
+
connection: connection_payload(conn_status),
|
|
259
263
|
stream: stream_info,
|
|
260
264
|
performance: {
|
|
261
265
|
nats_rtt_ms: rtt_ms,
|
|
262
266
|
health_check_duration_ms: health_check_duration_ms
|
|
263
267
|
},
|
|
264
|
-
config:
|
|
265
|
-
env: config.env,
|
|
266
|
-
app_name: config.app_name,
|
|
267
|
-
destination_app: config.destination_app,
|
|
268
|
-
use_outbox: config.use_outbox,
|
|
269
|
-
use_inbox: config.use_inbox,
|
|
270
|
-
use_dlq: config.use_dlq
|
|
271
|
-
},
|
|
268
|
+
config: config_summary,
|
|
272
269
|
version: JetstreamBridge::VERSION
|
|
273
270
|
}
|
|
274
271
|
rescue StandardError => e
|
|
@@ -416,5 +413,60 @@ module JetstreamBridge
|
|
|
416
413
|
consumer
|
|
417
414
|
end
|
|
418
415
|
end
|
|
416
|
+
|
|
417
|
+
private
|
|
418
|
+
|
|
419
|
+
def connection_snapshot(conn_instance, skip_cache:)
|
|
420
|
+
{
|
|
421
|
+
connected: conn_instance.connected?(skip_cache: skip_cache),
|
|
422
|
+
connected_at: conn_instance.connected_at,
|
|
423
|
+
state: conn_instance.state,
|
|
424
|
+
last_error: conn_instance.last_reconnect_error,
|
|
425
|
+
last_error_at: conn_instance.last_reconnect_error_at
|
|
426
|
+
}
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def stream_status(connected)
|
|
430
|
+
return stream_missing unless connected
|
|
431
|
+
return skipped_stream_info unless config.auto_provision
|
|
432
|
+
|
|
433
|
+
fetch_stream_info
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def stream_missing
|
|
437
|
+
{ exists: false, name: config.stream_name }
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def health_flag(connected, stream_info)
|
|
441
|
+
return connected unless config.auto_provision
|
|
442
|
+
|
|
443
|
+
connected && stream_info&.fetch(:exists, false)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def connection_payload(status)
|
|
447
|
+
{
|
|
448
|
+
state: status[:state],
|
|
449
|
+
connected: status[:connected],
|
|
450
|
+
connected_at: status[:connected_at]&.iso8601,
|
|
451
|
+
last_error: status[:last_error]&.message,
|
|
452
|
+
last_error_at: status[:last_error_at]&.iso8601
|
|
453
|
+
}
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def config_summary
|
|
457
|
+
{
|
|
458
|
+
app_name: config.app_name,
|
|
459
|
+
destination_app: config.destination_app,
|
|
460
|
+
stream_name: config.stream_name,
|
|
461
|
+
auto_provision: config.auto_provision,
|
|
462
|
+
use_outbox: config.use_outbox,
|
|
463
|
+
use_inbox: config.use_inbox,
|
|
464
|
+
use_dlq: config.use_dlq
|
|
465
|
+
}
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def elapsed_ms(start_time)
|
|
469
|
+
((Time.now - start_time) * 1000).round(2)
|
|
470
|
+
end
|
|
419
471
|
end
|
|
420
472
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jetstream_bridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Attara
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-01-
|
|
11
|
+
date: 2026-01-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -105,7 +105,7 @@ dependencies:
|
|
|
105
105
|
- !ruby/object:Gem::Version
|
|
106
106
|
version: '4.0'
|
|
107
107
|
description: |-
|
|
108
|
-
Production-ready publishers/consumers for NATS JetStream with
|
|
108
|
+
Production-ready publishers/consumers for NATS JetStream with app-scoped
|
|
109
109
|
subjects, overlap guards, DLQ routing, retries/backoff, and optional inbox/outbox
|
|
110
110
|
patterns. Includes health checks, auto-reconnection, graceful shutdown, topology
|
|
111
111
|
setup helpers, and Rails generators.
|
|
@@ -123,6 +123,7 @@ files:
|
|
|
123
123
|
- README.md
|
|
124
124
|
- docs/GETTING_STARTED.md
|
|
125
125
|
- docs/PRODUCTION.md
|
|
126
|
+
- docs/RESTRICTED_PERMISSIONS.md
|
|
126
127
|
- docs/TESTING.md
|
|
127
128
|
- lib/generators/jetstream_bridge/health_check/health_check_generator.rb
|
|
128
129
|
- lib/generators/jetstream_bridge/health_check/templates/health_controller.rb
|
|
@@ -160,6 +161,7 @@ files:
|
|
|
160
161
|
- lib/jetstream_bridge/models/outbox_event.rb
|
|
161
162
|
- lib/jetstream_bridge/models/publish_result.rb
|
|
162
163
|
- lib/jetstream_bridge/models/subject.rb
|
|
164
|
+
- lib/jetstream_bridge/provisioner.rb
|
|
163
165
|
- lib/jetstream_bridge/publisher/batch_publisher.rb
|
|
164
166
|
- lib/jetstream_bridge/publisher/outbox_repository.rb
|
|
165
167
|
- lib/jetstream_bridge/publisher/publisher.rb
|