jetstream_bridge 4.5.0 → 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 +338 -87
- data/README.md +3 -13
- data/docs/GETTING_STARTED.md +8 -12
- data/docs/PRODUCTION.md +13 -35
- data/docs/RESTRICTED_PERMISSIONS.md +399 -0
- data/docs/TESTING.md +33 -22
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
- data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
- data/lib/jetstream_bridge/core/config.rb +32 -161
- data/lib/jetstream_bridge/core/connection.rb +508 -0
- data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
- data/lib/jetstream_bridge/core.rb +2 -0
- data/lib/jetstream_bridge/models/subject.rb +15 -23
- data/lib/jetstream_bridge/provisioner.rb +67 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
- data/lib/jetstream_bridge/rails/integration.rb +5 -8
- data/lib/jetstream_bridge/rails/railtie.rb +3 -4
- data/lib/jetstream_bridge/tasks/install.rake +17 -1
- data/lib/jetstream_bridge/topology/topology.rb +1 -6
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +345 -202
- metadata +8 -8
- data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
- data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
- data/lib/jetstream_bridge/core/health_checker.rb +0 -184
- data/lib/jetstream_bridge/facade.rb +0 -212
- data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +0 -110
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.5.
|
|
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
|
|
@@ -135,7 +136,6 @@ files:
|
|
|
135
136
|
- lib/jetstream_bridge.rb
|
|
136
137
|
- lib/jetstream_bridge/consumer/consumer.rb
|
|
137
138
|
- lib/jetstream_bridge/consumer/dlq_publisher.rb
|
|
138
|
-
- lib/jetstream_bridge/consumer/health_monitor.rb
|
|
139
139
|
- lib/jetstream_bridge/consumer/inbox/inbox_message.rb
|
|
140
140
|
- lib/jetstream_bridge/consumer/inbox/inbox_processor.rb
|
|
141
141
|
- lib/jetstream_bridge/consumer/inbox/inbox_repository.rb
|
|
@@ -143,26 +143,26 @@ files:
|
|
|
143
143
|
- lib/jetstream_bridge/consumer/middleware.rb
|
|
144
144
|
- lib/jetstream_bridge/consumer/subscription_manager.rb
|
|
145
145
|
- lib/jetstream_bridge/core.rb
|
|
146
|
+
- lib/jetstream_bridge/core/bridge_helpers.rb
|
|
146
147
|
- lib/jetstream_bridge/core/config.rb
|
|
147
148
|
- lib/jetstream_bridge/core/config_preset.rb
|
|
148
|
-
- lib/jetstream_bridge/core/
|
|
149
|
+
- lib/jetstream_bridge/core/connection.rb
|
|
150
|
+
- lib/jetstream_bridge/core/connection_factory.rb
|
|
149
151
|
- lib/jetstream_bridge/core/debug_helper.rb
|
|
150
152
|
- lib/jetstream_bridge/core/duration.rb
|
|
151
|
-
- lib/jetstream_bridge/core/health_checker.rb
|
|
152
153
|
- lib/jetstream_bridge/core/logging.rb
|
|
153
154
|
- lib/jetstream_bridge/core/model_codec_setup.rb
|
|
154
155
|
- lib/jetstream_bridge/core/model_utils.rb
|
|
155
156
|
- lib/jetstream_bridge/core/retry_strategy.rb
|
|
156
157
|
- lib/jetstream_bridge/errors.rb
|
|
157
|
-
- lib/jetstream_bridge/facade.rb
|
|
158
158
|
- lib/jetstream_bridge/models/event.rb
|
|
159
159
|
- lib/jetstream_bridge/models/event_envelope.rb
|
|
160
160
|
- lib/jetstream_bridge/models/inbox_event.rb
|
|
161
161
|
- lib/jetstream_bridge/models/outbox_event.rb
|
|
162
162
|
- lib/jetstream_bridge/models/publish_result.rb
|
|
163
163
|
- lib/jetstream_bridge/models/subject.rb
|
|
164
|
+
- lib/jetstream_bridge/provisioner.rb
|
|
164
165
|
- lib/jetstream_bridge/publisher/batch_publisher.rb
|
|
165
|
-
- lib/jetstream_bridge/publisher/event_envelope_builder.rb
|
|
166
166
|
- lib/jetstream_bridge/publisher/outbox_repository.rb
|
|
167
167
|
- lib/jetstream_bridge/publisher/publisher.rb
|
|
168
168
|
- lib/jetstream_bridge/rails.rb
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative '../core/logging'
|
|
4
|
-
|
|
5
|
-
module JetstreamBridge
|
|
6
|
-
# Health monitoring for Consumer instances
|
|
7
|
-
#
|
|
8
|
-
# Responsible for:
|
|
9
|
-
# - Periodic health checks
|
|
10
|
-
# - Memory usage monitoring
|
|
11
|
-
# - Heap object tracking
|
|
12
|
-
# - GC recommendations
|
|
13
|
-
class ConsumerHealthMonitor
|
|
14
|
-
# Health check interval in seconds (10 minutes)
|
|
15
|
-
HEALTH_CHECK_INTERVAL = 600
|
|
16
|
-
# Memory warning threshold in MB
|
|
17
|
-
MEMORY_WARNING_THRESHOLD_MB = 1000
|
|
18
|
-
# Heap object count warning threshold
|
|
19
|
-
HEAP_OBJECT_WARNING_THRESHOLD = 200_000
|
|
20
|
-
|
|
21
|
-
def initialize(consumer_name)
|
|
22
|
-
@consumer_name = consumer_name
|
|
23
|
-
@start_time = Time.now
|
|
24
|
-
@last_check = Time.now
|
|
25
|
-
@iterations = 0
|
|
26
|
-
@gc_warning_logged = false
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Increment iteration counter
|
|
30
|
-
def increment_iterations
|
|
31
|
-
@iterations += 1
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Check if health check is due and perform if needed
|
|
35
|
-
#
|
|
36
|
-
# @return [Boolean] true if check was performed
|
|
37
|
-
def check_health_if_due
|
|
38
|
-
now = Time.now
|
|
39
|
-
time_since_check = now - @last_check
|
|
40
|
-
|
|
41
|
-
return false unless time_since_check >= HEALTH_CHECK_INTERVAL
|
|
42
|
-
|
|
43
|
-
perform_check(now)
|
|
44
|
-
true
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def perform_check(now)
|
|
50
|
-
@last_check = now
|
|
51
|
-
uptime = now - @start_time
|
|
52
|
-
memory_mb = memory_usage_mb
|
|
53
|
-
|
|
54
|
-
Logging.info(
|
|
55
|
-
"Consumer health: iterations=#{@iterations}, " \
|
|
56
|
-
"memory=#{memory_mb}MB, uptime=#{uptime.round}s",
|
|
57
|
-
tag: "JetstreamBridge::Consumer(#{@consumer_name})"
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
warn_if_high_memory(memory_mb)
|
|
61
|
-
suggest_gc_if_needed
|
|
62
|
-
rescue StandardError => e
|
|
63
|
-
Logging.debug(
|
|
64
|
-
"Health check failed: #{e.class} #{e.message}",
|
|
65
|
-
tag: "JetstreamBridge::Consumer(#{@consumer_name})"
|
|
66
|
-
)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def memory_usage_mb
|
|
70
|
-
# Get memory usage from OS (works on Linux/macOS)
|
|
71
|
-
rss_kb = `ps -o rss= -p #{Process.pid}`.to_i
|
|
72
|
-
rss_kb / 1024.0
|
|
73
|
-
rescue StandardError
|
|
74
|
-
0.0
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def warn_if_high_memory(memory_mb)
|
|
78
|
-
return unless memory_mb > MEMORY_WARNING_THRESHOLD_MB
|
|
79
|
-
|
|
80
|
-
Logging.warn(
|
|
81
|
-
"High memory usage detected: #{memory_mb}MB",
|
|
82
|
-
tag: "JetstreamBridge::Consumer(#{@consumer_name})"
|
|
83
|
-
)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def suggest_gc_if_needed
|
|
87
|
-
return unless defined?(GC) && GC.respond_to?(:stat)
|
|
88
|
-
|
|
89
|
-
stats = GC.stat
|
|
90
|
-
heap_live_slots = stats[:heap_live_slots] || stats['heap_live_slots'] || 0
|
|
91
|
-
|
|
92
|
-
return if heap_live_slots < HEAP_OBJECT_WARNING_THRESHOLD || @gc_warning_logged
|
|
93
|
-
|
|
94
|
-
@gc_warning_logged = true
|
|
95
|
-
Logging.warn(
|
|
96
|
-
"High heap object count detected (#{heap_live_slots}); " \
|
|
97
|
-
'consider profiling or manual GC in the host app',
|
|
98
|
-
tag: "JetstreamBridge::Consumer(#{@consumer_name})"
|
|
99
|
-
)
|
|
100
|
-
rescue StandardError => e
|
|
101
|
-
Logging.debug(
|
|
102
|
-
"GC check failed: #{e.class} #{e.message}",
|
|
103
|
-
tag: "JetstreamBridge::Consumer(#{@consumer_name})"
|
|
104
|
-
)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|
|
@@ -1,513 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'nats/io/client'
|
|
4
|
-
require 'uri'
|
|
5
|
-
require_relative 'logging'
|
|
6
|
-
require_relative 'config'
|
|
7
|
-
require_relative '../topology/topology'
|
|
8
|
-
|
|
9
|
-
module JetstreamBridge
|
|
10
|
-
# Manages NATS connection lifecycle
|
|
11
|
-
#
|
|
12
|
-
# Responsible for:
|
|
13
|
-
# - Establishing and closing connections
|
|
14
|
-
# - Validating connection URLs
|
|
15
|
-
# - Health checking
|
|
16
|
-
# - Reconnection handling
|
|
17
|
-
# - JetStream context management
|
|
18
|
-
#
|
|
19
|
-
# NOT a singleton - instances are created and injected
|
|
20
|
-
class ConnectionManager
|
|
21
|
-
# Connection states for observability
|
|
22
|
-
module State
|
|
23
|
-
DISCONNECTED = :disconnected
|
|
24
|
-
CONNECTING = :connecting
|
|
25
|
-
CONNECTED = :connected
|
|
26
|
-
RECONNECTING = :reconnecting
|
|
27
|
-
FAILED = :failed
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Valid NATS URL schemes
|
|
31
|
-
VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
|
|
32
|
-
|
|
33
|
-
DEFAULT_CONN_OPTS = {
|
|
34
|
-
reconnect: true,
|
|
35
|
-
reconnect_time_wait: 2,
|
|
36
|
-
max_reconnect_attempts: 10,
|
|
37
|
-
connect_timeout: 5
|
|
38
|
-
}.freeze
|
|
39
|
-
|
|
40
|
-
# Health check cache TTL in seconds
|
|
41
|
-
HEALTH_CHECK_CACHE_TTL = 30
|
|
42
|
-
|
|
43
|
-
attr_reader :config, :connected_at, :last_reconnect_error, :last_reconnect_error_at, :state
|
|
44
|
-
|
|
45
|
-
# @param config [Config] Configuration instance
|
|
46
|
-
def initialize(config)
|
|
47
|
-
@config = config
|
|
48
|
-
@mutex = Mutex.new
|
|
49
|
-
@state = State::DISCONNECTED
|
|
50
|
-
@nc = nil
|
|
51
|
-
@jts = nil
|
|
52
|
-
@connected_at = nil
|
|
53
|
-
@cached_health_status = nil
|
|
54
|
-
@last_health_check = nil
|
|
55
|
-
@reconnecting = false
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Establish connection to NATS and ensure topology
|
|
59
|
-
#
|
|
60
|
-
# Idempotent - safe to call multiple times
|
|
61
|
-
#
|
|
62
|
-
# @return [NATS::JetStream::JS] JetStream context
|
|
63
|
-
# @raise [ConnectionError] If connection fails
|
|
64
|
-
def connect!
|
|
65
|
-
@mutex.synchronize do
|
|
66
|
-
return @jts if connected_without_lock?
|
|
67
|
-
|
|
68
|
-
servers = validate_and_parse_servers!(@config.nats_urls)
|
|
69
|
-
@state = State::CONNECTING
|
|
70
|
-
|
|
71
|
-
establish_connection_with_retry(servers)
|
|
72
|
-
|
|
73
|
-
Logging.info(
|
|
74
|
-
"Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
|
|
75
|
-
"#{sanitize_urls(servers).join(', ')}",
|
|
76
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
ensure_topology_if_enabled
|
|
80
|
-
|
|
81
|
-
@connected_at = Time.now.utc
|
|
82
|
-
@state = State::CONNECTED
|
|
83
|
-
@jts
|
|
84
|
-
end
|
|
85
|
-
rescue StandardError
|
|
86
|
-
@state = State::FAILED
|
|
87
|
-
cleanup_connection!
|
|
88
|
-
raise
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Close the NATS connection
|
|
92
|
-
#
|
|
93
|
-
# @return [void]
|
|
94
|
-
def disconnect!
|
|
95
|
-
@mutex.synchronize do
|
|
96
|
-
cleanup_connection!(close_nc: true)
|
|
97
|
-
@state = State::DISCONNECTED
|
|
98
|
-
Logging.info('Disconnected from NATS', tag: 'JetstreamBridge::ConnectionManager')
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Reconnect to NATS (disconnect + connect)
|
|
103
|
-
#
|
|
104
|
-
# @return [void]
|
|
105
|
-
def reconnect!
|
|
106
|
-
Logging.info('Reconnecting to NATS...', tag: 'JetstreamBridge::ConnectionManager')
|
|
107
|
-
disconnect!
|
|
108
|
-
connect!
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Get JetStream context
|
|
112
|
-
#
|
|
113
|
-
# @return [NATS::JetStream::JS, nil] JetStream context if connected
|
|
114
|
-
def jetstream
|
|
115
|
-
@jts
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Get raw NATS client
|
|
119
|
-
#
|
|
120
|
-
# @return [NATS::IO::Client, nil] NATS client if connected
|
|
121
|
-
def nats_client
|
|
122
|
-
@nc
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Check if connected and healthy
|
|
126
|
-
#
|
|
127
|
-
# @param skip_cache [Boolean] Force fresh health check
|
|
128
|
-
# @return [Boolean] true if connected and healthy
|
|
129
|
-
def connected?(skip_cache: false)
|
|
130
|
-
return false unless @nc&.connected?
|
|
131
|
-
return false unless @jts
|
|
132
|
-
|
|
133
|
-
# Use cached result if available and fresh
|
|
134
|
-
now = Time.now.to_i
|
|
135
|
-
if !skip_cache && @last_health_check && (now - @last_health_check) < HEALTH_CHECK_CACHE_TTL
|
|
136
|
-
return @cached_health_status
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# Thread-safe cache update
|
|
140
|
-
@mutex.synchronize do
|
|
141
|
-
# Double-check after acquiring lock
|
|
142
|
-
now = Time.now.to_i
|
|
143
|
-
if !skip_cache && @last_health_check && (now - @last_health_check) < HEALTH_CHECK_CACHE_TTL
|
|
144
|
-
return @cached_health_status
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Perform actual health check
|
|
148
|
-
@cached_health_status = jetstream_healthy?
|
|
149
|
-
@last_health_check = now
|
|
150
|
-
@cached_health_status
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Get detailed health status
|
|
155
|
-
#
|
|
156
|
-
# @param skip_cache [Boolean] Force fresh check
|
|
157
|
-
# @return [Hash] Health status details
|
|
158
|
-
def health_check(skip_cache: false)
|
|
159
|
-
{
|
|
160
|
-
connected: connected?(skip_cache: skip_cache),
|
|
161
|
-
state: @state,
|
|
162
|
-
connected_at: @connected_at&.iso8601,
|
|
163
|
-
last_error: @last_reconnect_error&.message,
|
|
164
|
-
last_error_at: @last_reconnect_error_at&.iso8601
|
|
165
|
-
}
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
private
|
|
169
|
-
|
|
170
|
-
def connected_without_lock?
|
|
171
|
-
@jts && @nc&.connected?
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def jetstream_healthy?
|
|
175
|
-
return true if @config.disable_js_api
|
|
176
|
-
|
|
177
|
-
# Verify JetStream responds to simple API call
|
|
178
|
-
@jts.account_info
|
|
179
|
-
true
|
|
180
|
-
rescue StandardError => e
|
|
181
|
-
Logging.warn(
|
|
182
|
-
"JetStream health check failed: #{e.class} #{e.message}",
|
|
183
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
184
|
-
)
|
|
185
|
-
false
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def establish_connection_with_retry(servers)
|
|
189
|
-
attempts = 0
|
|
190
|
-
max_attempts = @config.connect_retry_attempts
|
|
191
|
-
retry_delay = @config.connect_retry_delay
|
|
192
|
-
|
|
193
|
-
begin
|
|
194
|
-
attempts += 1
|
|
195
|
-
establish_connection(servers)
|
|
196
|
-
rescue ConnectionError => e
|
|
197
|
-
if attempts < max_attempts
|
|
198
|
-
delay = retry_delay * attempts
|
|
199
|
-
Logging.warn(
|
|
200
|
-
"Connection attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
201
|
-
"Retrying in #{delay}s...",
|
|
202
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
203
|
-
)
|
|
204
|
-
sleep(delay)
|
|
205
|
-
retry
|
|
206
|
-
else
|
|
207
|
-
Logging.error(
|
|
208
|
-
"Failed to establish connection after #{attempts} attempts",
|
|
209
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
210
|
-
)
|
|
211
|
-
raise
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def establish_connection(servers)
|
|
217
|
-
@nc = create_nats_client
|
|
218
|
-
|
|
219
|
-
setup_callbacks
|
|
220
|
-
|
|
221
|
-
connect_opts = { servers: servers }.merge(DEFAULT_CONN_OPTS)
|
|
222
|
-
inbox_prefix = @config.inbox_prefix.to_s.strip
|
|
223
|
-
connect_opts[:inbox_prefix] = inbox_prefix unless inbox_prefix.empty?
|
|
224
|
-
|
|
225
|
-
@nc.connect(connect_opts) unless skip_connect?
|
|
226
|
-
|
|
227
|
-
verify_connection!
|
|
228
|
-
|
|
229
|
-
# Create JetStream context
|
|
230
|
-
@jts = @nc.jetstream
|
|
231
|
-
|
|
232
|
-
verify_jetstream!
|
|
233
|
-
|
|
234
|
-
# Ensure JetStream responds to #nc
|
|
235
|
-
add_nc_accessor unless @jts.respond_to?(:nc)
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def create_nats_client
|
|
239
|
-
# Use mock NATS client if explicitly enabled for testing
|
|
240
|
-
if test_mode?
|
|
241
|
-
JetstreamBridge.instance_variable_get(:@mock_nats_client)
|
|
242
|
-
else
|
|
243
|
-
NATS::IO::Client.new
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
def test_mode?
|
|
248
|
-
defined?(JetstreamBridge::TestHelpers) &&
|
|
249
|
-
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
250
|
-
JetstreamBridge::TestHelpers.test_mode? &&
|
|
251
|
-
JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def skip_connect?
|
|
255
|
-
@nc.connected? && test_mode?
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def setup_callbacks
|
|
259
|
-
@nc.on_reconnect do
|
|
260
|
-
@reconnecting = true
|
|
261
|
-
Logging.info(
|
|
262
|
-
'NATS reconnected, refreshing JetStream context',
|
|
263
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
264
|
-
)
|
|
265
|
-
refresh_jetstream_context
|
|
266
|
-
@reconnecting = false
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
@nc.on_disconnect do |reason|
|
|
270
|
-
@state = State::DISCONNECTED
|
|
271
|
-
Logging.warn(
|
|
272
|
-
"NATS disconnected: #{reason}",
|
|
273
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
274
|
-
)
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
@nc.on_error do |err|
|
|
278
|
-
Logging.error(
|
|
279
|
-
"NATS error: #{err}",
|
|
280
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
281
|
-
)
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def add_nc_accessor
|
|
286
|
-
nc_ref = @nc
|
|
287
|
-
@jts.define_singleton_method(:nc) { nc_ref }
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def verify_connection!
|
|
291
|
-
Logging.debug(
|
|
292
|
-
'Verifying NATS connection...',
|
|
293
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
unless @nc.connected?
|
|
297
|
-
Logging.error(
|
|
298
|
-
'NATS connection verification failed - client not connected',
|
|
299
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
300
|
-
)
|
|
301
|
-
raise ConnectionError, 'Failed to establish connection to NATS server(s)'
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
Logging.info(
|
|
305
|
-
'NATS connection verified successfully',
|
|
306
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
307
|
-
)
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def verify_jetstream!
|
|
311
|
-
return true if @config.disable_js_api
|
|
312
|
-
|
|
313
|
-
Logging.debug(
|
|
314
|
-
'Verifying JetStream availability...',
|
|
315
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
account_info = @jts.account_info
|
|
319
|
-
|
|
320
|
-
# Handle both object-style and hash-style access for compatibility
|
|
321
|
-
streams = account_info.respond_to?(:streams) ? account_info.streams : account_info[:streams]
|
|
322
|
-
consumers = account_info.respond_to?(:consumers) ? account_info.consumers : account_info[:consumers]
|
|
323
|
-
memory = account_info.respond_to?(:memory) ? account_info.memory : account_info[:memory]
|
|
324
|
-
storage = account_info.respond_to?(:storage) ? account_info.storage : account_info[:storage]
|
|
325
|
-
|
|
326
|
-
Logging.info(
|
|
327
|
-
"JetStream verified - Streams: #{streams}, " \
|
|
328
|
-
"Consumers: #{consumers}, " \
|
|
329
|
-
"Memory: #{format_bytes(memory)}, " \
|
|
330
|
-
"Storage: #{format_bytes(storage)}",
|
|
331
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
332
|
-
)
|
|
333
|
-
rescue NATS::IO::NoRespondersError
|
|
334
|
-
Logging.error(
|
|
335
|
-
'JetStream not available - no responders (JetStream not enabled)',
|
|
336
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
337
|
-
)
|
|
338
|
-
raise ConnectionError, 'JetStream not enabled on NATS server. Please enable JetStream with -js flag'
|
|
339
|
-
rescue StandardError => e
|
|
340
|
-
Logging.error(
|
|
341
|
-
"JetStream verification failed: #{e.class} - #{e.message}",
|
|
342
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
343
|
-
)
|
|
344
|
-
raise ConnectionError, "JetStream verification failed: #{e.message}"
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
def ensure_topology_if_enabled
|
|
348
|
-
return if @config.disable_js_api
|
|
349
|
-
|
|
350
|
-
Topology.ensure!(@jts, force: true)
|
|
351
|
-
rescue StandardError => e
|
|
352
|
-
Logging.warn(
|
|
353
|
-
"Topology ensure skipped: #{e.class} #{e.message}",
|
|
354
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
355
|
-
)
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
def refresh_jetstream_context
|
|
359
|
-
@jts = @nc.jetstream
|
|
360
|
-
add_nc_accessor unless @jts.respond_to?(:nc)
|
|
361
|
-
ensure_topology_if_enabled
|
|
362
|
-
|
|
363
|
-
# Invalidate health check cache on successful reconnect
|
|
364
|
-
@cached_health_status = nil
|
|
365
|
-
@last_health_check = nil
|
|
366
|
-
|
|
367
|
-
# Clear error state on successful reconnect
|
|
368
|
-
@last_reconnect_error = nil
|
|
369
|
-
@last_reconnect_error_at = nil
|
|
370
|
-
@state = State::CONNECTED
|
|
371
|
-
|
|
372
|
-
Logging.info(
|
|
373
|
-
'JetStream context refreshed successfully after reconnect',
|
|
374
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
375
|
-
)
|
|
376
|
-
rescue StandardError => e
|
|
377
|
-
# Store error state for diagnostics
|
|
378
|
-
@last_reconnect_error = e
|
|
379
|
-
@last_reconnect_error_at = Time.now
|
|
380
|
-
@state = State::FAILED
|
|
381
|
-
cleanup_connection!(close_nc: false)
|
|
382
|
-
Logging.error(
|
|
383
|
-
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
384
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
385
|
-
)
|
|
386
|
-
|
|
387
|
-
# Invalidate health check cache to force re-check
|
|
388
|
-
@cached_health_status = false
|
|
389
|
-
@last_health_check = Time.now.to_i
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def cleanup_connection!(close_nc: true)
|
|
393
|
-
begin
|
|
394
|
-
# Avoid touching RSpec doubles used in unit tests
|
|
395
|
-
is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
|
|
396
|
-
@nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
|
|
397
|
-
rescue StandardError
|
|
398
|
-
# ignore cleanup errors
|
|
399
|
-
end
|
|
400
|
-
@nc = nil
|
|
401
|
-
@jts = nil
|
|
402
|
-
@cached_health_status = nil
|
|
403
|
-
@last_health_check = nil
|
|
404
|
-
@connected_at = nil
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
def format_bytes(bytes)
|
|
408
|
-
return 'N/A' if bytes.nil? || bytes.zero?
|
|
409
|
-
|
|
410
|
-
units = %w[B KB MB GB TB]
|
|
411
|
-
exp = (Math.log(bytes) / Math.log(1024)).to_i
|
|
412
|
-
exp = [exp, units.length - 1].min
|
|
413
|
-
"#{(bytes / (1024.0**exp)).round(2)} #{units[exp]}"
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
def sanitize_urls(urls)
|
|
417
|
-
urls.map { |u| Logging.sanitize_url(u) }
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
# Validate and parse NATS server URLs
|
|
421
|
-
#
|
|
422
|
-
# @param urls [String] Comma-separated NATS URLs
|
|
423
|
-
# @return [Array<String>] Validated server URLs
|
|
424
|
-
# @raise [ConnectionError] If validation fails
|
|
425
|
-
def validate_and_parse_servers!(urls)
|
|
426
|
-
servers = parse_urls(urls)
|
|
427
|
-
validate_not_empty!(servers)
|
|
428
|
-
|
|
429
|
-
servers.each { |url| validate_url!(url) }
|
|
430
|
-
|
|
431
|
-
Logging.info(
|
|
432
|
-
'All NATS URLs validated successfully',
|
|
433
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
434
|
-
)
|
|
435
|
-
|
|
436
|
-
servers
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
def parse_urls(urls)
|
|
440
|
-
urls.to_s
|
|
441
|
-
.split(',')
|
|
442
|
-
.map(&:strip)
|
|
443
|
-
.reject(&:empty?)
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
def validate_not_empty!(servers)
|
|
447
|
-
return unless servers.empty?
|
|
448
|
-
|
|
449
|
-
raise ConnectionError, 'No NATS URLs configured'
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
def validate_url!(url)
|
|
453
|
-
validate_url_format!(url)
|
|
454
|
-
|
|
455
|
-
uri = URI.parse(url)
|
|
456
|
-
validate_url_scheme!(uri, url)
|
|
457
|
-
validate_url_host!(uri, url)
|
|
458
|
-
validate_url_port!(uri, url)
|
|
459
|
-
|
|
460
|
-
Logging.debug(
|
|
461
|
-
"URL validated: #{Logging.sanitize_url(url)}",
|
|
462
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
463
|
-
)
|
|
464
|
-
rescue URI::InvalidURIError => e
|
|
465
|
-
Logging.error(
|
|
466
|
-
"Malformed URL: #{url} (#{e.message})",
|
|
467
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
468
|
-
)
|
|
469
|
-
raise ConnectionError, "Invalid NATS URL format: #{url} (#{e.message})"
|
|
470
|
-
end
|
|
471
|
-
|
|
472
|
-
def validate_url_format!(url)
|
|
473
|
-
return if url.include?('://')
|
|
474
|
-
|
|
475
|
-
Logging.error(
|
|
476
|
-
"Invalid URL format (missing scheme): #{url}",
|
|
477
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
478
|
-
)
|
|
479
|
-
raise ConnectionError, "Invalid NATS URL format: #{url}. Expected format: nats://host:port"
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
def validate_url_scheme!(uri, url)
|
|
483
|
-
scheme = uri.scheme&.downcase
|
|
484
|
-
return if VALID_NATS_SCHEMES.include?(scheme)
|
|
485
|
-
|
|
486
|
-
Logging.error(
|
|
487
|
-
"Invalid URL scheme '#{uri.scheme}': #{Logging.sanitize_url(url)}",
|
|
488
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
489
|
-
)
|
|
490
|
-
raise ConnectionError, "Invalid NATS URL scheme '#{uri.scheme}' in: #{url}. Expected 'nats' or 'nats+tls'"
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
def validate_url_host!(uri, url)
|
|
494
|
-
return unless uri.host.nil? || uri.host.empty?
|
|
495
|
-
|
|
496
|
-
Logging.error(
|
|
497
|
-
"Missing host in URL: #{Logging.sanitize_url(url)}",
|
|
498
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
499
|
-
)
|
|
500
|
-
raise ConnectionError, "Invalid NATS URL - missing host: #{url}"
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
def validate_url_port!(uri, url)
|
|
504
|
-
return unless uri.port && (uri.port < 1 || uri.port > 65_535)
|
|
505
|
-
|
|
506
|
-
Logging.error(
|
|
507
|
-
"Invalid port #{uri.port} in URL: #{Logging.sanitize_url(url)}",
|
|
508
|
-
tag: 'JetstreamBridge::ConnectionManager'
|
|
509
|
-
)
|
|
510
|
-
raise ConnectionError, "Invalid NATS URL - port must be 1-65535: #{url}"
|
|
511
|
-
end
|
|
512
|
-
end
|
|
513
|
-
end
|