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
|
@@ -0,0 +1,107 @@
|
|
|
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
|
|
@@ -198,7 +198,7 @@ module JetstreamBridge
|
|
|
198
198
|
|
|
199
199
|
def log_ack(result)
|
|
200
200
|
ctx = result.ctx
|
|
201
|
-
Logging.
|
|
201
|
+
Logging.debug(
|
|
202
202
|
"ACK event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} deliveries=#{ctx&.deliveries}",
|
|
203
203
|
tag: 'JetstreamBridge::Consumer'
|
|
204
204
|
)
|
|
@@ -10,6 +10,8 @@ module JetstreamBridge
|
|
|
10
10
|
@jts = jts
|
|
11
11
|
@durable = durable
|
|
12
12
|
@cfg = cfg
|
|
13
|
+
return if @cfg.disable_js_api
|
|
14
|
+
|
|
13
15
|
@desired_cfg = build_consumer_config(@durable, filter_subject)
|
|
14
16
|
@desired_cfg_norm = normalize_consumer_config(@desired_cfg)
|
|
15
17
|
end
|
|
@@ -27,6 +29,11 @@ module JetstreamBridge
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def ensure_consumer!
|
|
32
|
+
if @cfg.disable_js_api
|
|
33
|
+
Logging.info("JS API disabled; assuming consumer #{@durable} exists", tag: 'JetstreamBridge::Consumer')
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
info = consumer_info_or_nil
|
|
31
38
|
return create_consumer! unless info
|
|
32
39
|
|
|
@@ -41,11 +48,17 @@ module JetstreamBridge
|
|
|
41
48
|
|
|
42
49
|
# Bind a pull subscriber to the existing durable.
|
|
43
50
|
def subscribe!
|
|
51
|
+
opts = { stream: stream_name }
|
|
52
|
+
if @cfg.disable_js_api
|
|
53
|
+
opts[:bind] = true
|
|
54
|
+
else
|
|
55
|
+
opts[:config] = desired_consumer_cfg
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
@jts.pull_subscribe(
|
|
45
59
|
filter_subject,
|
|
46
60
|
@durable,
|
|
47
|
-
|
|
48
|
-
config: desired_consumer_cfg
|
|
61
|
+
**opts
|
|
49
62
|
)
|
|
50
63
|
end
|
|
51
64
|
|
|
@@ -80,9 +93,9 @@ module JetstreamBridge
|
|
|
80
93
|
ack_policy: 'explicit',
|
|
81
94
|
deliver_policy: 'all',
|
|
82
95
|
max_deliver: JetstreamBridge.config.max_deliver,
|
|
83
|
-
# JetStream expects
|
|
84
|
-
ack_wait:
|
|
85
|
-
backoff: Array(JetstreamBridge.config.backoff).map { |d|
|
|
96
|
+
# JetStream expects nanoseconds for ack_wait/backoff.
|
|
97
|
+
ack_wait: duration_to_nanos(JetstreamBridge.config.ack_wait),
|
|
98
|
+
backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_nanos(d) }
|
|
86
99
|
}
|
|
87
100
|
end
|
|
88
101
|
|
|
@@ -94,8 +107,8 @@ module JetstreamBridge
|
|
|
94
107
|
ack_policy: sval(cfg, :ack_policy), # string
|
|
95
108
|
deliver_policy: sval(cfg, :deliver_policy), # string
|
|
96
109
|
max_deliver: ival(cfg, :max_deliver), # integer
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
ack_wait_nanos: nanos(cfg, :ack_wait),
|
|
111
|
+
backoff_nanos: nanos_arr(cfg, :backoff)
|
|
99
112
|
}
|
|
100
113
|
end
|
|
101
114
|
|
|
@@ -151,54 +164,58 @@ module JetstreamBridge
|
|
|
151
164
|
v.to_i
|
|
152
165
|
end
|
|
153
166
|
|
|
154
|
-
#
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
# - Integers/Floats:
|
|
158
|
-
# * Server may return large integers in **nanoseconds** → detect and convert.
|
|
159
|
-
# * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
|
|
160
|
-
def d_secs(cfg, key)
|
|
167
|
+
# ---- duration coercion ----
|
|
168
|
+
|
|
169
|
+
def nanos(cfg, key)
|
|
161
170
|
raw = get(cfg, key)
|
|
162
|
-
|
|
171
|
+
duration_to_nanos(raw)
|
|
163
172
|
end
|
|
164
173
|
|
|
165
|
-
|
|
166
|
-
def darr_secs(cfg, key)
|
|
174
|
+
def nanos_arr(cfg, key)
|
|
167
175
|
raw = get(cfg, key)
|
|
168
|
-
Array(raw).map { |d|
|
|
176
|
+
Array(raw).map { |d| duration_to_nanos(d) }
|
|
169
177
|
end
|
|
170
178
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def duration_to_seconds(val)
|
|
179
|
+
def duration_to_nanos(val)
|
|
174
180
|
return nil if val.nil?
|
|
175
181
|
|
|
176
182
|
case val
|
|
177
183
|
when Integer
|
|
178
|
-
# Heuristic: extremely large integers are likely
|
|
179
|
-
|
|
180
|
-
return (val / 1_000_000_000.0).round if val >= 1_000_000_000
|
|
184
|
+
# Heuristic: extremely large integers are likely already nanoseconds
|
|
185
|
+
return val if val >= 1_000_000_000 # >= 1s in nanos
|
|
181
186
|
|
|
182
|
-
# otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
|
|
183
187
|
millis = Duration.to_millis(val, default_unit: :auto)
|
|
184
|
-
|
|
188
|
+
(millis * 1_000_000).to_i
|
|
185
189
|
when Float
|
|
186
190
|
millis = Duration.to_millis(val, default_unit: :auto)
|
|
187
|
-
|
|
191
|
+
(millis * 1_000_000).to_i
|
|
188
192
|
when String
|
|
189
|
-
|
|
190
|
-
millis
|
|
191
|
-
seconds_from_millis(millis)
|
|
193
|
+
millis = Duration.to_millis(val) # unit-aware
|
|
194
|
+
(millis * 1_000_000).to_i
|
|
192
195
|
else
|
|
193
|
-
return
|
|
196
|
+
return duration_to_nanos(val.to_f) if val.respond_to?(:to_f)
|
|
194
197
|
|
|
195
198
|
raise ArgumentError, "invalid duration: #{val.inspect}"
|
|
196
199
|
end
|
|
197
200
|
end
|
|
198
201
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
# Legacy helper used in specs; kept for backward compatibility.
|
|
203
|
+
def duration_to_seconds(val)
|
|
204
|
+
return nil if val.nil?
|
|
205
|
+
|
|
206
|
+
case val
|
|
207
|
+
when Integer
|
|
208
|
+
return (val / 1_000_000_000.0).round if val >= 1_000_000_000 # nanoseconds
|
|
209
|
+
return val if val < 1000 # seconds
|
|
210
|
+
|
|
211
|
+
(val / 1000.0).round # milliseconds as integer
|
|
212
|
+
when Float
|
|
213
|
+
val
|
|
214
|
+
else
|
|
215
|
+
millis = Duration.to_millis(val, default_unit: :auto)
|
|
216
|
+
seconds = millis / 1000.0
|
|
217
|
+
seconds < 1 ? 1 : seconds.round(3)
|
|
218
|
+
end
|
|
202
219
|
end
|
|
203
220
|
end
|
|
204
221
|
end
|
|
@@ -11,7 +11,6 @@ module JetstreamBridge
|
|
|
11
11
|
# @example Basic configuration
|
|
12
12
|
# JetstreamBridge.configure do |config|
|
|
13
13
|
# config.nats_urls = "nats://localhost:4222"
|
|
14
|
-
# config.env = "production"
|
|
15
14
|
# config.app_name = "api_service"
|
|
16
15
|
# config.destination_app = "worker_service"
|
|
17
16
|
# config.use_outbox = true
|
|
@@ -50,11 +49,7 @@ module JetstreamBridge
|
|
|
50
49
|
# NATS server URL(s)
|
|
51
50
|
# @return [String]
|
|
52
51
|
attr_accessor :nats_urls
|
|
53
|
-
# Environment namespace (development, staging, production)
|
|
54
|
-
# @return [String]
|
|
55
|
-
attr_accessor :env
|
|
56
52
|
# Application name for subject routing
|
|
57
|
-
# @return [String]
|
|
58
53
|
attr_accessor :app_name
|
|
59
54
|
# Maximum delivery attempts before moving to DLQ
|
|
60
55
|
# @return [Integer]
|
|
@@ -95,12 +90,23 @@ module JetstreamBridge
|
|
|
95
90
|
# Enable lazy connection (connect on first use instead of during configure)
|
|
96
91
|
# @return [Boolean]
|
|
97
92
|
attr_accessor :lazy_connect
|
|
93
|
+
# Inbox prefix for request/reply (useful when NATS permissions restrict _INBOX.>)
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
attr_accessor :inbox_prefix
|
|
96
|
+
# Skip JetStream management API calls (account_info, stream ensure, etc.)
|
|
97
|
+
# Requires streams/consumers to be pre-provisioned and permissions handled externally.
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
attr_accessor :disable_js_api
|
|
100
|
+
# Optional durable consumer name
|
|
101
|
+
attr_writer :durable_name
|
|
98
102
|
|
|
99
103
|
def initialize
|
|
100
104
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
101
|
-
@env = ENV['NATS_ENV'] || 'development'
|
|
102
105
|
@app_name = ENV['APP_NAME'] || 'app'
|
|
103
106
|
@destination_app = ENV.fetch('DESTINATION_APP', nil)
|
|
107
|
+
@stream_name = ENV.fetch('STREAM_NAME', nil)
|
|
108
|
+
@stream_name_set = !@stream_name.nil?
|
|
109
|
+
@durable_name = ENV.fetch('DURABLE_NAME', nil)
|
|
104
110
|
|
|
105
111
|
@max_deliver = 5
|
|
106
112
|
@ack_wait = '30s'
|
|
@@ -118,6 +124,8 @@ module JetstreamBridge
|
|
|
118
124
|
@connect_retry_attempts = 3
|
|
119
125
|
@connect_retry_delay = 2
|
|
120
126
|
@lazy_connect = false
|
|
127
|
+
@inbox_prefix = ENV['NATS_INBOX_PREFIX'] || '_INBOX'
|
|
128
|
+
@disable_js_api = (ENV['JETSTREAM_DISABLE_JS_API'] || 'true') == 'true'
|
|
121
129
|
end
|
|
122
130
|
|
|
123
131
|
# Apply a configuration preset
|
|
@@ -131,85 +139,83 @@ module JetstreamBridge
|
|
|
131
139
|
self
|
|
132
140
|
end
|
|
133
141
|
|
|
134
|
-
# Get the JetStream stream name
|
|
142
|
+
# Get the JetStream stream name (required).
|
|
135
143
|
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
# config.stream_name # => "production-jetstream-bridge-stream"
|
|
144
|
+
# Side-effect free query. Use validate! to ensure validity.
|
|
145
|
+
#
|
|
146
|
+
# @return [String, nil] Stream name
|
|
140
147
|
def stream_name
|
|
141
|
-
|
|
148
|
+
@cached_stream_name || (@stream_name_set ? @stream_name : default_stream_name)
|
|
142
149
|
end
|
|
143
150
|
|
|
144
151
|
# Get the NATS subject this application publishes to.
|
|
145
152
|
#
|
|
146
|
-
# Producer publishes to: {
|
|
147
|
-
# Consumer subscribes to: {
|
|
153
|
+
# Producer publishes to: {app}.sync.{dest}
|
|
154
|
+
# Consumer subscribes to: {dest}.sync.{app}
|
|
155
|
+
#
|
|
156
|
+
# Side-effect free query. Use validate! to ensure validity.
|
|
148
157
|
#
|
|
149
|
-
# @return [String] Source subject for publishing
|
|
150
|
-
# @raise [InvalidSubjectError] If components contain NATS wildcards
|
|
151
|
-
# @raise [MissingConfigurationError] If required components empty
|
|
158
|
+
# @return [String, nil] Source subject for publishing
|
|
152
159
|
# @example
|
|
153
|
-
# config.env = "production"
|
|
154
160
|
# config.app_name = "api"
|
|
155
161
|
# config.destination_app = "worker"
|
|
156
|
-
# config.source_subject # => "
|
|
162
|
+
# config.source_subject # => "api.sync.worker"
|
|
157
163
|
def source_subject
|
|
158
|
-
|
|
159
|
-
validate_subject_component!(app_name, 'app_name')
|
|
160
|
-
validate_subject_component!(destination_app, 'destination_app')
|
|
161
|
-
"#{env}.#{app_name}.sync.#{destination_app}"
|
|
164
|
+
@cached_source_subject || build_source_subject
|
|
162
165
|
end
|
|
163
166
|
|
|
164
167
|
# Get the NATS subject this application subscribes to.
|
|
165
168
|
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
# @
|
|
169
|
+
# Side-effect free query. Use validate! to ensure validity.
|
|
170
|
+
#
|
|
171
|
+
# @return [String, nil] Destination subject for consuming
|
|
169
172
|
# @example
|
|
170
|
-
# config.env = "production"
|
|
171
173
|
# config.app_name = "api"
|
|
172
174
|
# config.destination_app = "worker"
|
|
173
|
-
# config.destination_subject # => "
|
|
175
|
+
# config.destination_subject # => "worker.sync.api"
|
|
174
176
|
def destination_subject
|
|
175
|
-
|
|
176
|
-
validate_subject_component!(app_name, 'app_name')
|
|
177
|
-
validate_subject_component!(destination_app, 'destination_app')
|
|
178
|
-
"#{env}.#{destination_app}.sync.#{app_name}"
|
|
177
|
+
@cached_destination_subject || build_destination_subject
|
|
179
178
|
end
|
|
180
179
|
|
|
181
180
|
# Get the dead letter queue subject for this application.
|
|
182
181
|
#
|
|
183
182
|
# Each app has its own DLQ for better isolation and monitoring.
|
|
184
183
|
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
# @
|
|
184
|
+
# Side-effect free query. Use validate! to ensure validity.
|
|
185
|
+
#
|
|
186
|
+
# @return [String, nil] DLQ subject in format "{app_name}.sync.dlq"
|
|
188
187
|
# @example
|
|
189
|
-
# config.env = "production"
|
|
190
188
|
# config.app_name = "api"
|
|
191
|
-
# config.dlq_subject # => "
|
|
189
|
+
# config.dlq_subject # => "api.sync.dlq"
|
|
192
190
|
def dlq_subject
|
|
193
|
-
|
|
194
|
-
validate_subject_component!(app_name, 'app_name')
|
|
195
|
-
"#{env}.#{app_name}.sync.dlq"
|
|
191
|
+
@cached_dlq_subject || build_dlq_subject
|
|
196
192
|
end
|
|
197
193
|
|
|
198
194
|
# Get the durable consumer name for this application.
|
|
199
195
|
#
|
|
200
|
-
# @return [String] Durable name in format "{
|
|
196
|
+
# @return [String] Durable name in format "{app_name}-workers"
|
|
201
197
|
# @example
|
|
202
|
-
# config.env = "production"
|
|
203
198
|
# config.app_name = "api"
|
|
204
|
-
# config.durable_name # => "
|
|
199
|
+
# config.durable_name # => "api-workers"
|
|
205
200
|
def durable_name
|
|
206
|
-
|
|
201
|
+
value = @durable_name
|
|
202
|
+
return "#{app_name}-workers" if value.to_s.strip.empty?
|
|
203
|
+
|
|
204
|
+
value
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def stream_name=(value)
|
|
208
|
+
@stream_name_set = true
|
|
209
|
+
@stream_name = value
|
|
207
210
|
end
|
|
208
211
|
|
|
209
212
|
# Validate all configuration settings.
|
|
210
213
|
#
|
|
211
214
|
# Checks that required settings are present and valid. Raises errors
|
|
212
|
-
# for any invalid configuration.
|
|
215
|
+
# for any invalid configuration. Caches computed subjects after validation.
|
|
216
|
+
#
|
|
217
|
+
# This is a command method - performs validation and updates internal state.
|
|
218
|
+
# Call this once after configuration is complete.
|
|
213
219
|
#
|
|
214
220
|
# @return [true] If configuration is valid
|
|
215
221
|
# @raise [ConfigurationError] If any validation fails
|
|
@@ -217,21 +223,122 @@ module JetstreamBridge
|
|
|
217
223
|
# config.validate! # Raises if destination_app is missing
|
|
218
224
|
def validate!
|
|
219
225
|
errors = []
|
|
226
|
+
|
|
227
|
+
# Validate stream name
|
|
228
|
+
stream_val = @stream_name_set ? @stream_name : default_stream_name
|
|
229
|
+
if stream_val.to_s.strip.empty?
|
|
230
|
+
errors << 'stream_name is required'
|
|
231
|
+
else
|
|
232
|
+
begin
|
|
233
|
+
validate_stream_name!(stream_val)
|
|
234
|
+
rescue InvalidSubjectError => e
|
|
235
|
+
errors << e.message
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Validate required fields
|
|
220
240
|
errors << 'destination_app is required' if destination_app.to_s.strip.empty?
|
|
221
241
|
errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
|
|
222
|
-
errors << 'env is required' if env.to_s.strip.empty?
|
|
223
242
|
errors << 'app_name is required' if app_name.to_s.strip.empty?
|
|
224
243
|
errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
|
|
225
244
|
errors << 'backoff must be an array' unless backoff.is_a?(Array)
|
|
226
245
|
errors << 'backoff must not be empty' if backoff.is_a?(Array) && backoff.empty?
|
|
246
|
+
errors << 'disable_js_api must be boolean' unless [true, false].include?(disable_js_api)
|
|
247
|
+
|
|
248
|
+
# Validate inbox prefix
|
|
249
|
+
begin
|
|
250
|
+
validate_inbox_prefix!(inbox_prefix)
|
|
251
|
+
rescue InvalidSubjectError => e
|
|
252
|
+
errors << e.message
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Validate subject components
|
|
256
|
+
begin
|
|
257
|
+
validate_subject_component!(app_name, 'app_name') unless app_name.to_s.strip.empty?
|
|
258
|
+
validate_subject_component!(destination_app, 'destination_app') unless destination_app.to_s.strip.empty?
|
|
259
|
+
rescue InvalidSubjectError, MissingConfigurationError => e
|
|
260
|
+
errors << e.message
|
|
261
|
+
end
|
|
227
262
|
|
|
228
263
|
raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
|
|
229
264
|
|
|
265
|
+
# Cache computed values after successful validation
|
|
266
|
+
cache_computed_values!
|
|
267
|
+
|
|
230
268
|
true
|
|
231
269
|
end
|
|
232
270
|
|
|
233
271
|
private
|
|
234
272
|
|
|
273
|
+
# Cache computed subjects after validation
|
|
274
|
+
def cache_computed_values!
|
|
275
|
+
@cached_stream_name = @stream_name_set ? @stream_name : default_stream_name
|
|
276
|
+
@cached_source_subject = build_source_subject
|
|
277
|
+
@cached_destination_subject = build_destination_subject
|
|
278
|
+
@cached_dlq_subject = build_dlq_subject
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Build source subject without side effects
|
|
282
|
+
def build_source_subject
|
|
283
|
+
build_subject(app_name, 'sync', destination_app)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Build destination subject without side effects
|
|
287
|
+
def build_destination_subject
|
|
288
|
+
build_subject(destination_app, 'sync', app_name)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Build DLQ subject without side effects
|
|
292
|
+
def build_dlq_subject
|
|
293
|
+
return nil if app_name.to_s.strip.empty?
|
|
294
|
+
|
|
295
|
+
build_subject(app_name, 'sync', 'dlq')
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Generic subject builder
|
|
299
|
+
#
|
|
300
|
+
# @param parts [Array<String>] Subject parts to join
|
|
301
|
+
# @return [String, nil] Built subject or nil if any required part is empty
|
|
302
|
+
def build_subject(*parts)
|
|
303
|
+
# Check if any required parts are empty
|
|
304
|
+
return nil if parts.any? { |p| p.to_s.strip.empty? }
|
|
305
|
+
|
|
306
|
+
parts.join('.')
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_inbox_prefix!(value)
|
|
310
|
+
str = value.to_s.strip
|
|
311
|
+
raise InvalidSubjectError, 'inbox_prefix cannot be empty' if str.empty?
|
|
312
|
+
|
|
313
|
+
# Disallow wildcards, spaces, or control characters in inbox prefix
|
|
314
|
+
return unless str.match?(/[*> \t\r\n\x00-\x1F\x7F]/)
|
|
315
|
+
|
|
316
|
+
raise InvalidSubjectError,
|
|
317
|
+
"inbox_prefix contains invalid characters (wildcards, spaces, or control chars): #{value.inspect}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def validate_override!(name, value)
|
|
321
|
+
str = value.to_s.strip
|
|
322
|
+
raise InvalidSubjectError, "#{name} cannot be empty" if str.empty?
|
|
323
|
+
return unless str.match?(/[ \t\r\n\x00-\x1F\x7F]/)
|
|
324
|
+
|
|
325
|
+
raise InvalidSubjectError, "#{name} contains invalid whitespace/control characters: #{value.inspect}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def validate_stream_name!(value)
|
|
329
|
+
str = value.to_s.strip
|
|
330
|
+
raise MissingConfigurationError, 'stream_name is required' if str.empty?
|
|
331
|
+
|
|
332
|
+
return unless str.match?(/[*> \t\r\n\x00-\x1F\x7F]/)
|
|
333
|
+
|
|
334
|
+
raise InvalidSubjectError,
|
|
335
|
+
"stream_name contains invalid characters (wildcards, whitespace, or control chars): #{value.inspect}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def default_stream_name
|
|
339
|
+
nil
|
|
340
|
+
end
|
|
341
|
+
|
|
235
342
|
def validate_subject_component!(value, name)
|
|
236
343
|
str = value.to_s.strip
|
|
237
344
|
raise MissingConfigurationError, "#{name} cannot be empty" if str.empty?
|