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
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.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Attara
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -135,6 +135,7 @@ files:
|
|
|
135
135
|
- lib/jetstream_bridge.rb
|
|
136
136
|
- lib/jetstream_bridge/consumer/consumer.rb
|
|
137
137
|
- lib/jetstream_bridge/consumer/dlq_publisher.rb
|
|
138
|
+
- lib/jetstream_bridge/consumer/health_monitor.rb
|
|
138
139
|
- lib/jetstream_bridge/consumer/inbox/inbox_message.rb
|
|
139
140
|
- lib/jetstream_bridge/consumer/inbox/inbox_processor.rb
|
|
140
141
|
- lib/jetstream_bridge/consumer/inbox/inbox_repository.rb
|
|
@@ -142,18 +143,18 @@ files:
|
|
|
142
143
|
- lib/jetstream_bridge/consumer/middleware.rb
|
|
143
144
|
- lib/jetstream_bridge/consumer/subscription_manager.rb
|
|
144
145
|
- lib/jetstream_bridge/core.rb
|
|
145
|
-
- lib/jetstream_bridge/core/bridge_helpers.rb
|
|
146
146
|
- lib/jetstream_bridge/core/config.rb
|
|
147
147
|
- lib/jetstream_bridge/core/config_preset.rb
|
|
148
|
-
- lib/jetstream_bridge/core/
|
|
149
|
-
- lib/jetstream_bridge/core/connection_factory.rb
|
|
148
|
+
- lib/jetstream_bridge/core/connection_manager.rb
|
|
150
149
|
- lib/jetstream_bridge/core/debug_helper.rb
|
|
151
150
|
- lib/jetstream_bridge/core/duration.rb
|
|
151
|
+
- lib/jetstream_bridge/core/health_checker.rb
|
|
152
152
|
- lib/jetstream_bridge/core/logging.rb
|
|
153
153
|
- lib/jetstream_bridge/core/model_codec_setup.rb
|
|
154
154
|
- lib/jetstream_bridge/core/model_utils.rb
|
|
155
155
|
- lib/jetstream_bridge/core/retry_strategy.rb
|
|
156
156
|
- lib/jetstream_bridge/errors.rb
|
|
157
|
+
- lib/jetstream_bridge/facade.rb
|
|
157
158
|
- lib/jetstream_bridge/models/event.rb
|
|
158
159
|
- lib/jetstream_bridge/models/event_envelope.rb
|
|
159
160
|
- lib/jetstream_bridge/models/inbox_event.rb
|
|
@@ -161,6 +162,7 @@ files:
|
|
|
161
162
|
- lib/jetstream_bridge/models/publish_result.rb
|
|
162
163
|
- lib/jetstream_bridge/models/subject.rb
|
|
163
164
|
- lib/jetstream_bridge/publisher/batch_publisher.rb
|
|
165
|
+
- lib/jetstream_bridge/publisher/event_envelope_builder.rb
|
|
164
166
|
- lib/jetstream_bridge/publisher/outbox_repository.rb
|
|
165
167
|
- lib/jetstream_bridge/publisher/publisher.rb
|
|
166
168
|
- lib/jetstream_bridge/rails.rb
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'logging'
|
|
4
|
-
require_relative 'connection'
|
|
5
|
-
|
|
6
|
-
module JetstreamBridge
|
|
7
|
-
module Core
|
|
8
|
-
# Internal helper methods extracted from the main JetstreamBridge module
|
|
9
|
-
# to keep the public API surface focused.
|
|
10
|
-
module BridgeHelpers
|
|
11
|
-
private
|
|
12
|
-
|
|
13
|
-
# Ensure connection is established before use
|
|
14
|
-
# Automatically connects on first use if not already connected
|
|
15
|
-
# Thread-safe and idempotent
|
|
16
|
-
def connect_if_needed!
|
|
17
|
-
return if @connection_initialized
|
|
18
|
-
|
|
19
|
-
startup!
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Enforce rate limit on uncached health checks to prevent abuse
|
|
23
|
-
# Max 1 uncached request per 5 seconds per process
|
|
24
|
-
def enforce_health_check_rate_limit!
|
|
25
|
-
@health_check_mutex ||= Mutex.new
|
|
26
|
-
@health_check_mutex.synchronize do
|
|
27
|
-
now = Time.now
|
|
28
|
-
if @last_uncached_health_check
|
|
29
|
-
time_since = now - @last_uncached_health_check
|
|
30
|
-
if time_since < 5
|
|
31
|
-
raise HealthCheckFailedError,
|
|
32
|
-
"Health check rate limit exceeded. Please wait #{(5 - time_since).ceil} second(s)"
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
@last_uncached_health_check = now
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def fetch_stream_info
|
|
40
|
-
# Ensure we have an active connection before querying stream info
|
|
41
|
-
connect_if_needed!
|
|
42
|
-
|
|
43
|
-
jts = Connection.jetstream
|
|
44
|
-
raise ConnectionNotEstablishedError, 'NATS connection not established' unless jts
|
|
45
|
-
|
|
46
|
-
info = jts.stream_info(config.stream_name)
|
|
47
|
-
|
|
48
|
-
# Handle both object-style and hash-style access for compatibility
|
|
49
|
-
config_data = info.config
|
|
50
|
-
state_data = info.state
|
|
51
|
-
subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
|
|
52
|
-
messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
exists: true,
|
|
56
|
-
name: config.stream_name,
|
|
57
|
-
subjects: subjects,
|
|
58
|
-
messages: messages
|
|
59
|
-
}
|
|
60
|
-
rescue StandardError => e
|
|
61
|
-
{
|
|
62
|
-
exists: false,
|
|
63
|
-
name: config.stream_name,
|
|
64
|
-
error: "#{e.class}: #{e.message}"
|
|
65
|
-
}
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def measure_nats_rtt
|
|
69
|
-
nc = Connection.nc
|
|
70
|
-
return nil unless nc
|
|
71
|
-
|
|
72
|
-
# Prefer native RTT API when available (e.g., new NATS clients)
|
|
73
|
-
if nc.respond_to?(:rtt)
|
|
74
|
-
rtt_value = normalize_ms(nc.rtt)
|
|
75
|
-
return rtt_value if rtt_value
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Fallback for clients without #rtt (nats-pure): measure ping/pong via flush
|
|
79
|
-
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
80
|
-
nc.flush(1)
|
|
81
|
-
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
82
|
-
normalize_ms(duration)
|
|
83
|
-
rescue StandardError => e
|
|
84
|
-
Logging.warn(
|
|
85
|
-
"Failed to measure NATS RTT: #{e.class} #{e.message}",
|
|
86
|
-
tag: 'JetstreamBridge'
|
|
87
|
-
)
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def normalize_ms(value)
|
|
92
|
-
return nil if value.nil?
|
|
93
|
-
return nil unless value.respond_to?(:to_f)
|
|
94
|
-
|
|
95
|
-
numeric = value.to_f
|
|
96
|
-
# Heuristic: sub-1 values are likely seconds; convert them to ms for reporting
|
|
97
|
-
ms = numeric < 1 ? numeric * 1000 : numeric
|
|
98
|
-
ms.round(2)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def assign_config_option!(cfg, key, val)
|
|
102
|
-
setter = :"#{key}="
|
|
103
|
-
raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
|
|
104
|
-
|
|
105
|
-
cfg.public_send(setter, val)
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'nats/io/client'
|
|
4
|
-
require 'singleton'
|
|
5
|
-
require 'oj'
|
|
6
|
-
require_relative 'duration'
|
|
7
|
-
require_relative 'logging'
|
|
8
|
-
require_relative 'config'
|
|
9
|
-
require_relative '../topology/topology'
|
|
10
|
-
|
|
11
|
-
module JetstreamBridge
|
|
12
|
-
# Singleton connection to NATS with thread-safe initialization.
|
|
13
|
-
#
|
|
14
|
-
# This class manages a single NATS connection for the entire application,
|
|
15
|
-
# ensuring thread-safe access in multi-threaded environments like Rails
|
|
16
|
-
# with Puma or Sidekiq.
|
|
17
|
-
#
|
|
18
|
-
# Thread Safety:
|
|
19
|
-
# - Connection initialization is synchronized with a mutex
|
|
20
|
-
# - The singleton pattern ensures only one connection instance exists
|
|
21
|
-
# - Safe to call from multiple threads/workers simultaneously
|
|
22
|
-
#
|
|
23
|
-
# Example:
|
|
24
|
-
# # Safe from any thread
|
|
25
|
-
# jts = JetstreamBridge::Connection.connect!
|
|
26
|
-
# jts.publish(...)
|
|
27
|
-
class Connection
|
|
28
|
-
include Singleton
|
|
29
|
-
|
|
30
|
-
# Connection states for observability
|
|
31
|
-
module State
|
|
32
|
-
DISCONNECTED = :disconnected
|
|
33
|
-
CONNECTING = :connecting
|
|
34
|
-
CONNECTED = :connected
|
|
35
|
-
RECONNECTING = :reconnecting
|
|
36
|
-
FAILED = :failed
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
DEFAULT_CONN_OPTS = {
|
|
40
|
-
reconnect: true,
|
|
41
|
-
reconnect_time_wait: 2,
|
|
42
|
-
max_reconnect_attempts: 10,
|
|
43
|
-
connect_timeout: 5
|
|
44
|
-
}.freeze
|
|
45
|
-
|
|
46
|
-
VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
|
|
47
|
-
|
|
48
|
-
# Class-level mutex for thread-safe connection initialization
|
|
49
|
-
# Using class variable to avoid race condition in mutex creation
|
|
50
|
-
# rubocop:disable Style/ClassVars
|
|
51
|
-
@@connection_lock = Mutex.new
|
|
52
|
-
# rubocop:enable Style/ClassVars
|
|
53
|
-
|
|
54
|
-
class << self
|
|
55
|
-
# Thread-safe delegator to the singleton instance.
|
|
56
|
-
# Returns a live JetStream context.
|
|
57
|
-
#
|
|
58
|
-
# Safe to call from multiple threads - uses class-level mutex for synchronization.
|
|
59
|
-
#
|
|
60
|
-
# @return [NATS::JetStream::JS] JetStream context
|
|
61
|
-
def connect!
|
|
62
|
-
@@connection_lock.synchronize { instance.connect! }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Optional accessors if callers need raw handles
|
|
66
|
-
def nc
|
|
67
|
-
instance.__send__(:nc)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def jetstream
|
|
71
|
-
instance.__send__(:jetstream)
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Idempotent: returns an existing, healthy JetStream context or establishes one.
|
|
76
|
-
def connect!
|
|
77
|
-
# Check if already connected without acquiring mutex (for performance)
|
|
78
|
-
return @jts if @jts && @nc&.connected?
|
|
79
|
-
|
|
80
|
-
servers = nats_servers
|
|
81
|
-
raise 'No NATS URLs configured' if servers.empty?
|
|
82
|
-
|
|
83
|
-
@state = State::CONNECTING
|
|
84
|
-
establish_connection_with_retry(servers)
|
|
85
|
-
|
|
86
|
-
Logging.info(
|
|
87
|
-
"Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
|
|
88
|
-
"#{sanitize_urls(servers).join(', ')}",
|
|
89
|
-
tag: 'JetstreamBridge::Connection'
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
# Ensure topology (streams, subjects, overlap guard, etc.)
|
|
93
|
-
Topology.ensure!(@jts)
|
|
94
|
-
|
|
95
|
-
@connected_at = Time.now.utc
|
|
96
|
-
@state = State::CONNECTED
|
|
97
|
-
@jts
|
|
98
|
-
rescue StandardError
|
|
99
|
-
@state = State::FAILED
|
|
100
|
-
cleanup_connection!
|
|
101
|
-
raise
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Public API for checking connection status
|
|
105
|
-
#
|
|
106
|
-
# Uses cached health check result to avoid excessive network calls.
|
|
107
|
-
# Cache expires after 30 seconds.
|
|
108
|
-
#
|
|
109
|
-
# Thread-safe: Cache updates are synchronized to prevent race conditions.
|
|
110
|
-
#
|
|
111
|
-
# @param skip_cache [Boolean] Force fresh health check, bypass cache
|
|
112
|
-
# @return [Boolean] true if NATS client is connected and JetStream is healthy
|
|
113
|
-
def connected?(skip_cache: false)
|
|
114
|
-
return false unless @nc&.connected?
|
|
115
|
-
return false unless @jts
|
|
116
|
-
|
|
117
|
-
# Use cached result if available and fresh
|
|
118
|
-
now = Time.now.to_i
|
|
119
|
-
return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
|
|
120
|
-
|
|
121
|
-
# Thread-safe cache update to prevent race conditions
|
|
122
|
-
@@connection_lock.synchronize do
|
|
123
|
-
# Double-check after acquiring lock (another thread may have updated)
|
|
124
|
-
now = Time.now.to_i
|
|
125
|
-
return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
|
|
126
|
-
|
|
127
|
-
# Perform actual health check
|
|
128
|
-
@cached_health_status = jetstream_healthy?
|
|
129
|
-
@last_health_check = now
|
|
130
|
-
@cached_health_status
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Public API for getting connection timestamp
|
|
135
|
-
# @return [Time, nil] timestamp when connection was established
|
|
136
|
-
attr_reader :connected_at
|
|
137
|
-
|
|
138
|
-
# Last reconnection error metadata (exposed for health checks/diagnostics)
|
|
139
|
-
attr_reader :last_reconnect_error, :last_reconnect_error_at
|
|
140
|
-
|
|
141
|
-
# Get current connection state
|
|
142
|
-
#
|
|
143
|
-
# @return [Symbol] Current connection state (see State module)
|
|
144
|
-
def state
|
|
145
|
-
return State::DISCONNECTED unless @nc
|
|
146
|
-
return State::FAILED if @last_reconnect_error && !@nc.connected?
|
|
147
|
-
return State::RECONNECTING if @reconnecting
|
|
148
|
-
|
|
149
|
-
@nc.connected? ? (@state || State::CONNECTED) : State::DISCONNECTED
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
private
|
|
153
|
-
|
|
154
|
-
def jetstream_healthy?
|
|
155
|
-
# Verify JetStream responds to simple API call
|
|
156
|
-
@jts.account_info
|
|
157
|
-
true
|
|
158
|
-
rescue StandardError => e
|
|
159
|
-
Logging.warn(
|
|
160
|
-
"JetStream health check failed: #{e.class} #{e.message}",
|
|
161
|
-
tag: 'JetstreamBridge::Connection'
|
|
162
|
-
)
|
|
163
|
-
false
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def nats_servers
|
|
167
|
-
servers = JetstreamBridge.config.nats_urls
|
|
168
|
-
.to_s
|
|
169
|
-
.split(',')
|
|
170
|
-
.map(&:strip)
|
|
171
|
-
.reject(&:empty?)
|
|
172
|
-
|
|
173
|
-
validate_nats_urls!(servers)
|
|
174
|
-
servers
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def establish_connection_with_retry(servers)
|
|
178
|
-
attempts = 0
|
|
179
|
-
max_attempts = JetstreamBridge.config.connect_retry_attempts
|
|
180
|
-
retry_delay = JetstreamBridge.config.connect_retry_delay
|
|
181
|
-
|
|
182
|
-
begin
|
|
183
|
-
attempts += 1
|
|
184
|
-
establish_connection(servers)
|
|
185
|
-
rescue ConnectionError => e
|
|
186
|
-
if attempts < max_attempts
|
|
187
|
-
delay = retry_delay * attempts
|
|
188
|
-
Logging.warn(
|
|
189
|
-
"Connection attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
190
|
-
"Retrying in #{delay}s...",
|
|
191
|
-
tag: 'JetstreamBridge::Connection'
|
|
192
|
-
)
|
|
193
|
-
sleep(delay)
|
|
194
|
-
retry
|
|
195
|
-
else
|
|
196
|
-
Logging.error(
|
|
197
|
-
"Failed to establish connection after #{attempts} attempts",
|
|
198
|
-
tag: 'JetstreamBridge::Connection'
|
|
199
|
-
)
|
|
200
|
-
cleanup_connection!
|
|
201
|
-
raise
|
|
202
|
-
end
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def establish_connection(servers)
|
|
207
|
-
# Use mock NATS client if explicitly enabled for testing
|
|
208
|
-
# This allows test helpers to inject a mock without affecting normal operation
|
|
209
|
-
@nc = if defined?(JetstreamBridge::TestHelpers) &&
|
|
210
|
-
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
211
|
-
JetstreamBridge::TestHelpers.test_mode? &&
|
|
212
|
-
JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
|
|
213
|
-
JetstreamBridge.instance_variable_get(:@mock_nats_client)
|
|
214
|
-
else
|
|
215
|
-
NATS::IO::Client.new
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Setup reconnect handler to refresh JetStream context
|
|
219
|
-
@nc.on_reconnect do
|
|
220
|
-
@reconnecting = true
|
|
221
|
-
Logging.info(
|
|
222
|
-
'NATS reconnected, refreshing JetStream context',
|
|
223
|
-
tag: 'JetstreamBridge::Connection'
|
|
224
|
-
)
|
|
225
|
-
refresh_jetstream_context
|
|
226
|
-
@reconnecting = false
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
@nc.on_disconnect do |reason|
|
|
230
|
-
@state = State::DISCONNECTED
|
|
231
|
-
Logging.warn(
|
|
232
|
-
"NATS disconnected: #{reason}",
|
|
233
|
-
tag: 'JetstreamBridge::Connection'
|
|
234
|
-
)
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
@nc.on_error do |err|
|
|
238
|
-
Logging.error(
|
|
239
|
-
"NATS error: #{err}",
|
|
240
|
-
tag: 'JetstreamBridge::Connection'
|
|
241
|
-
)
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Only connect if not already connected (mock may be pre-connected)
|
|
245
|
-
# Note: For test helpers mock, skip connect. For RSpec mocks, always call connect
|
|
246
|
-
skip_connect = @nc.connected? &&
|
|
247
|
-
defined?(JetstreamBridge::TestHelpers) &&
|
|
248
|
-
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
249
|
-
JetstreamBridge::TestHelpers.test_mode?
|
|
250
|
-
|
|
251
|
-
@nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS)) unless skip_connect
|
|
252
|
-
|
|
253
|
-
# Verify connection is established
|
|
254
|
-
verify_connection!
|
|
255
|
-
|
|
256
|
-
# Create JetStream context
|
|
257
|
-
@jts = @nc.jetstream
|
|
258
|
-
|
|
259
|
-
# Verify JetStream is available
|
|
260
|
-
verify_jetstream!
|
|
261
|
-
|
|
262
|
-
# Ensure JetStream responds to #nc
|
|
263
|
-
return if @jts.respond_to?(:nc)
|
|
264
|
-
|
|
265
|
-
nc_ref = @nc
|
|
266
|
-
@jts.define_singleton_method(:nc) { nc_ref }
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
def validate_nats_urls!(servers)
|
|
270
|
-
Logging.debug(
|
|
271
|
-
"Validating #{servers.size} NATS URL(s): #{sanitize_urls(servers).join(', ')}",
|
|
272
|
-
tag: 'JetstreamBridge::Connection'
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
servers.each do |url|
|
|
276
|
-
# Check for basic URL format (scheme://host)
|
|
277
|
-
unless url.include?('://')
|
|
278
|
-
Logging.error(
|
|
279
|
-
"Invalid URL format (missing scheme): #{url}",
|
|
280
|
-
tag: 'JetstreamBridge::Connection'
|
|
281
|
-
)
|
|
282
|
-
raise ConnectionError, "Invalid NATS URL format: #{url}. Expected format: nats://host:port"
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
uri = URI.parse(url)
|
|
286
|
-
|
|
287
|
-
# Validate scheme
|
|
288
|
-
scheme = uri.scheme&.downcase
|
|
289
|
-
unless VALID_NATS_SCHEMES.include?(scheme)
|
|
290
|
-
Logging.error(
|
|
291
|
-
"Invalid URL scheme '#{uri.scheme}': #{Logging.sanitize_url(url)}",
|
|
292
|
-
tag: 'JetstreamBridge::Connection'
|
|
293
|
-
)
|
|
294
|
-
raise ConnectionError, "Invalid NATS URL scheme '#{uri.scheme}' in: #{url}. Expected 'nats' or 'nats+tls'"
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Validate host is present
|
|
298
|
-
if uri.host.nil? || uri.host.empty?
|
|
299
|
-
Logging.error(
|
|
300
|
-
"Missing host in URL: #{Logging.sanitize_url(url)}",
|
|
301
|
-
tag: 'JetstreamBridge::Connection'
|
|
302
|
-
)
|
|
303
|
-
raise ConnectionError, "Invalid NATS URL - missing host: #{url}"
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Validate port if present
|
|
307
|
-
if uri.port && (uri.port < 1 || uri.port > 65_535)
|
|
308
|
-
Logging.error(
|
|
309
|
-
"Invalid port #{uri.port} in URL: #{Logging.sanitize_url(url)}",
|
|
310
|
-
tag: 'JetstreamBridge::Connection'
|
|
311
|
-
)
|
|
312
|
-
raise ConnectionError, "Invalid NATS URL - port must be 1-65535: #{url}"
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
Logging.debug(
|
|
316
|
-
"URL validated: #{Logging.sanitize_url(url)}",
|
|
317
|
-
tag: 'JetstreamBridge::Connection'
|
|
318
|
-
)
|
|
319
|
-
rescue URI::InvalidURIError => e
|
|
320
|
-
Logging.error(
|
|
321
|
-
"Malformed URL: #{url} (#{e.message})",
|
|
322
|
-
tag: 'JetstreamBridge::Connection'
|
|
323
|
-
)
|
|
324
|
-
raise ConnectionError, "Invalid NATS URL format: #{url} (#{e.message})"
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
Logging.info(
|
|
328
|
-
'All NATS URLs validated successfully',
|
|
329
|
-
tag: 'JetstreamBridge::Connection'
|
|
330
|
-
)
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def verify_connection!
|
|
334
|
-
Logging.debug(
|
|
335
|
-
'Verifying NATS connection...',
|
|
336
|
-
tag: 'JetstreamBridge::Connection'
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
unless @nc.connected?
|
|
340
|
-
Logging.error(
|
|
341
|
-
'NATS connection verification failed - client not connected',
|
|
342
|
-
tag: 'JetstreamBridge::Connection'
|
|
343
|
-
)
|
|
344
|
-
raise ConnectionError, 'Failed to establish connection to NATS server(s)'
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
Logging.info(
|
|
348
|
-
'NATS connection verified successfully',
|
|
349
|
-
tag: 'JetstreamBridge::Connection'
|
|
350
|
-
)
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def verify_jetstream!
|
|
354
|
-
Logging.debug(
|
|
355
|
-
'Verifying JetStream availability...',
|
|
356
|
-
tag: 'JetstreamBridge::Connection'
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
# Verify JetStream is enabled by checking account info
|
|
360
|
-
account_info = @jts.account_info
|
|
361
|
-
|
|
362
|
-
# Handle both object-style and hash-style access for compatibility
|
|
363
|
-
streams = account_info.respond_to?(:streams) ? account_info.streams : account_info[:streams]
|
|
364
|
-
consumers = account_info.respond_to?(:consumers) ? account_info.consumers : account_info[:consumers]
|
|
365
|
-
memory = account_info.respond_to?(:memory) ? account_info.memory : account_info[:memory]
|
|
366
|
-
storage = account_info.respond_to?(:storage) ? account_info.storage : account_info[:storage]
|
|
367
|
-
|
|
368
|
-
Logging.info(
|
|
369
|
-
"JetStream verified - Streams: #{streams}, " \
|
|
370
|
-
"Consumers: #{consumers}, " \
|
|
371
|
-
"Memory: #{format_bytes(memory)}, " \
|
|
372
|
-
"Storage: #{format_bytes(storage)}",
|
|
373
|
-
tag: 'JetstreamBridge::Connection'
|
|
374
|
-
)
|
|
375
|
-
rescue NATS::IO::NoRespondersError
|
|
376
|
-
Logging.error(
|
|
377
|
-
'JetStream not available - no responders (JetStream not enabled)',
|
|
378
|
-
tag: 'JetstreamBridge::Connection'
|
|
379
|
-
)
|
|
380
|
-
raise ConnectionError, 'JetStream not enabled on NATS server. Please enable JetStream with -js flag'
|
|
381
|
-
rescue StandardError => e
|
|
382
|
-
Logging.error(
|
|
383
|
-
"JetStream verification failed: #{e.class} - #{e.message}",
|
|
384
|
-
tag: 'JetstreamBridge::Connection'
|
|
385
|
-
)
|
|
386
|
-
raise ConnectionError, "JetStream verification failed: #{e.message}"
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
def format_bytes(bytes)
|
|
390
|
-
return 'N/A' if bytes.nil? || bytes.zero?
|
|
391
|
-
|
|
392
|
-
units = %w[B KB MB GB TB]
|
|
393
|
-
exp = (Math.log(bytes) / Math.log(1024)).to_i
|
|
394
|
-
exp = [exp, units.length - 1].min
|
|
395
|
-
"#{(bytes / (1024.0**exp)).round(2)} #{units[exp]}"
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
def refresh_jetstream_context
|
|
399
|
-
@jts = @nc.jetstream
|
|
400
|
-
nc_ref = @nc
|
|
401
|
-
@jts.define_singleton_method(:nc) { nc_ref } unless @jts.respond_to?(:nc)
|
|
402
|
-
|
|
403
|
-
# Re-ensure topology after reconnect
|
|
404
|
-
Topology.ensure!(@jts)
|
|
405
|
-
|
|
406
|
-
# Invalidate health check cache on successful reconnect
|
|
407
|
-
@cached_health_status = nil
|
|
408
|
-
@last_health_check = nil
|
|
409
|
-
|
|
410
|
-
# Clear error state on successful reconnect
|
|
411
|
-
@last_reconnect_error = nil
|
|
412
|
-
@last_reconnect_error_at = nil
|
|
413
|
-
@state = State::CONNECTED
|
|
414
|
-
|
|
415
|
-
Logging.info(
|
|
416
|
-
'JetStream context refreshed successfully after reconnect',
|
|
417
|
-
tag: 'JetstreamBridge::Connection'
|
|
418
|
-
)
|
|
419
|
-
rescue StandardError => e
|
|
420
|
-
# Store error state for diagnostics
|
|
421
|
-
@last_reconnect_error = e
|
|
422
|
-
@last_reconnect_error_at = Time.now
|
|
423
|
-
@state = State::FAILED
|
|
424
|
-
cleanup_connection!(close_nc: false)
|
|
425
|
-
Logging.error(
|
|
426
|
-
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
427
|
-
tag: 'JetstreamBridge::Connection'
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
# Invalidate health check cache to force re-check
|
|
431
|
-
@cached_health_status = false
|
|
432
|
-
@last_health_check = Time.now.to_i
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
# Expose for class-level helpers (not part of public API)
|
|
436
|
-
attr_reader :nc
|
|
437
|
-
|
|
438
|
-
def jetstream
|
|
439
|
-
@jts
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# Mask credentials in NATS URLs:
|
|
443
|
-
# - "nats://user:pass@host:4222" -> "nats://user:***@host:4222"
|
|
444
|
-
# - "nats://token@host:4222" -> "nats://***@host:4222"
|
|
445
|
-
def sanitize_urls(urls)
|
|
446
|
-
urls.map { |u| Logging.sanitize_url(u) }
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
def cleanup_connection!(close_nc: true)
|
|
450
|
-
begin
|
|
451
|
-
# Avoid touching RSpec doubles used in unit tests
|
|
452
|
-
is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
|
|
453
|
-
@nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
|
|
454
|
-
rescue StandardError
|
|
455
|
-
# ignore cleanup errors
|
|
456
|
-
end
|
|
457
|
-
@nc = nil
|
|
458
|
-
@jts = nil
|
|
459
|
-
@cached_health_status = nil
|
|
460
|
-
@last_health_check = nil
|
|
461
|
-
@connected_at = nil
|
|
462
|
-
end
|
|
463
|
-
end
|
|
464
|
-
end
|