jetstream_bridge 4.5.0 → 4.5.2
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 +525 -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 +59 -12
- 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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../core/logging'
|
|
4
4
|
require_relative '../core/duration'
|
|
5
|
+
require_relative '../errors'
|
|
5
6
|
|
|
6
7
|
module JetstreamBridge
|
|
7
8
|
# Encapsulates durable ensure + subscribe for a pull consumer.
|
|
@@ -10,10 +11,7 @@ module JetstreamBridge
|
|
|
10
11
|
@jts = jts
|
|
11
12
|
@durable = durable
|
|
12
13
|
@cfg = cfg
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@desired_cfg = build_consumer_config(@durable, filter_subject)
|
|
16
|
-
@desired_cfg_norm = normalize_consumer_config(@desired_cfg)
|
|
14
|
+
@desired_cfg = build_consumer_config(@durable, filter_subject)
|
|
17
15
|
end
|
|
18
16
|
|
|
19
17
|
def stream_name
|
|
@@ -28,64 +26,69 @@ module JetstreamBridge
|
|
|
28
26
|
@desired_cfg
|
|
29
27
|
end
|
|
30
28
|
|
|
31
|
-
def ensure_consumer!
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
def ensure_consumer!(force: false)
|
|
30
|
+
# Runtime path: never hit JetStream management APIs to avoid admin permissions.
|
|
31
|
+
unless force || @cfg.auto_provision
|
|
32
|
+
log_runtime_skip
|
|
34
33
|
return
|
|
35
34
|
end
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
return create_consumer! unless info
|
|
39
|
-
|
|
40
|
-
have_norm = normalize_consumer_config(info.config)
|
|
41
|
-
if have_norm == @desired_cfg_norm
|
|
42
|
-
log_consumer_ok
|
|
43
|
-
else
|
|
44
|
-
log_consumer_diff(have_norm)
|
|
45
|
-
recreate_consumer!
|
|
46
|
-
end
|
|
36
|
+
create_consumer!
|
|
47
37
|
end
|
|
48
38
|
|
|
49
39
|
# Bind a pull subscriber to the existing durable.
|
|
50
40
|
def subscribe!
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
# Always bypass consumer_info to avoid requiring JetStream API permissions at runtime.
|
|
42
|
+
subscribe_without_verification!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def subscribe_without_verification!
|
|
46
|
+
# Manually create a pull subscription without calling consumer_info
|
|
47
|
+
# This bypasses the permission check in nats-pure's pull_subscribe
|
|
48
|
+
nc = resolve_nc
|
|
49
|
+
|
|
50
|
+
if nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe)
|
|
51
|
+
prefix = @jts.instance_variable_get(:@prefix) || '$JS.API'
|
|
52
|
+
deliver = nc.new_inbox
|
|
53
|
+
sub = nc.subscribe(deliver)
|
|
54
|
+
|
|
55
|
+
# Extend with PullSubscription module to add fetch methods
|
|
56
|
+
sub.extend(NATS::JetStream::PullSubscription)
|
|
57
|
+
|
|
58
|
+
# Set up the JSI (JetStream Info) struct that PullSubscription expects
|
|
59
|
+
# This matches what nats-pure does in pull_subscribe
|
|
60
|
+
subject = "#{prefix}.CONSUMER.MSG.NEXT.#{stream_name}.#{@durable}"
|
|
61
|
+
sub.jsi = NATS::JetStream::JS::Sub.new(
|
|
62
|
+
js: @jts,
|
|
63
|
+
stream: stream_name,
|
|
64
|
+
consumer: @durable,
|
|
65
|
+
nms: subject
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
Logging.info(
|
|
69
|
+
"Created pull subscription without verification for consumer #{@durable} " \
|
|
70
|
+
"(stream=#{stream_name}, filter=#{filter_subject})",
|
|
71
|
+
tag: 'JetstreamBridge::Consumer'
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return sub
|
|
56
75
|
end
|
|
57
76
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
def consumer_info_or_nil
|
|
68
|
-
@jts.consumer_info(stream_name, @durable)
|
|
69
|
-
rescue NATS::JetStream::Error
|
|
70
|
-
nil
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# ---- comparison ----
|
|
74
|
-
|
|
75
|
-
def log_consumer_diff(have_norm)
|
|
76
|
-
want_norm = @desired_cfg_norm
|
|
77
|
-
|
|
78
|
-
diffs = {}
|
|
79
|
-
(have_norm.keys | want_norm.keys).each do |k|
|
|
80
|
-
diffs[k] = { have: have_norm[k], want: want_norm[k] } unless have_norm[k] == want_norm[k]
|
|
77
|
+
# Fallback for environments (mocks/tests) where low-level NATS client is unavailable.
|
|
78
|
+
if @jts.respond_to?(:pull_subscribe)
|
|
79
|
+
Logging.info(
|
|
80
|
+
"Using pull_subscribe fallback for consumer #{@durable} (stream=#{stream_name})",
|
|
81
|
+
tag: 'JetstreamBridge::Consumer'
|
|
82
|
+
)
|
|
83
|
+
return @jts.pull_subscribe(filter_subject, @durable, stream: stream_name)
|
|
81
84
|
end
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
tag: 'JetstreamBridge::Consumer'
|
|
86
|
-
)
|
|
86
|
+
raise JetstreamBridge::ConnectionError,
|
|
87
|
+
'Unable to create subscription without verification: NATS client not available'
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
private
|
|
91
|
+
|
|
89
92
|
def build_consumer_config(durable, filter_subject)
|
|
90
93
|
{
|
|
91
94
|
durable_name: durable,
|
|
@@ -93,36 +96,12 @@ module JetstreamBridge
|
|
|
93
96
|
ack_policy: 'explicit',
|
|
94
97
|
deliver_policy: 'all',
|
|
95
98
|
max_deliver: JetstreamBridge.config.max_deliver,
|
|
96
|
-
# JetStream expects
|
|
97
|
-
ack_wait:
|
|
98
|
-
backoff: Array(JetstreamBridge.config.backoff).map { |d|
|
|
99
|
+
# JetStream expects seconds (the client multiplies by nanoseconds).
|
|
100
|
+
ack_wait: duration_to_seconds(JetstreamBridge.config.ack_wait),
|
|
101
|
+
backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_seconds(d) }
|
|
99
102
|
}
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
# Normalize both server-returned config objects and our desired hash
|
|
103
|
-
# into a common hash with consistent units/types for accurate comparison.
|
|
104
|
-
def normalize_consumer_config(cfg)
|
|
105
|
-
{
|
|
106
|
-
filter_subject: sval(cfg, :filter_subject), # string
|
|
107
|
-
ack_policy: sval(cfg, :ack_policy), # string
|
|
108
|
-
deliver_policy: sval(cfg, :deliver_policy), # string
|
|
109
|
-
max_deliver: ival(cfg, :max_deliver), # integer
|
|
110
|
-
ack_wait_nanos: nanos(cfg, :ack_wait),
|
|
111
|
-
backoff_nanos: nanos_arr(cfg, :backoff)
|
|
112
|
-
}
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# ---- lifecycle helpers ----
|
|
116
|
-
|
|
117
|
-
def recreate_consumer!
|
|
118
|
-
Logging.warn(
|
|
119
|
-
"Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
|
|
120
|
-
tag: 'JetstreamBridge::Consumer'
|
|
121
|
-
)
|
|
122
|
-
safe_delete_consumer
|
|
123
|
-
create_consumer!
|
|
124
|
-
end
|
|
125
|
-
|
|
126
105
|
def create_consumer!
|
|
127
106
|
@jts.add_consumer(stream_name, **desired_consumer_cfg)
|
|
128
107
|
Logging.info(
|
|
@@ -131,22 +110,6 @@ module JetstreamBridge
|
|
|
131
110
|
)
|
|
132
111
|
end
|
|
133
112
|
|
|
134
|
-
def log_consumer_ok
|
|
135
|
-
Logging.info(
|
|
136
|
-
"Consumer #{@durable} exists with desired config.",
|
|
137
|
-
tag: 'JetstreamBridge::Consumer'
|
|
138
|
-
)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def safe_delete_consumer
|
|
142
|
-
@jts.delete_consumer(stream_name, @durable)
|
|
143
|
-
rescue NATS::JetStream::Error => e
|
|
144
|
-
Logging.warn(
|
|
145
|
-
"Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
|
|
146
|
-
tag: 'JetstreamBridge::Consumer'
|
|
147
|
-
)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
113
|
# ---- cfg access/normalization (struct-like or hash-like) ----
|
|
151
114
|
|
|
152
115
|
def get(cfg, key)
|
|
@@ -164,58 +127,71 @@ module JetstreamBridge
|
|
|
164
127
|
v.to_i
|
|
165
128
|
end
|
|
166
129
|
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
130
|
+
# Normalize duration-like field to **milliseconds** (Integer).
|
|
131
|
+
# Accepts:
|
|
132
|
+
# - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
|
|
133
|
+
# - Integers/Floats:
|
|
134
|
+
# * Server may return large integers in **nanoseconds** → detect and convert.
|
|
135
|
+
# * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
|
|
136
|
+
def d_secs(cfg, key)
|
|
170
137
|
raw = get(cfg, key)
|
|
171
|
-
|
|
138
|
+
duration_to_seconds(raw)
|
|
172
139
|
end
|
|
173
140
|
|
|
174
|
-
|
|
141
|
+
# Normalize array of durations to integer milliseconds.
|
|
142
|
+
def darr_secs(cfg, key)
|
|
175
143
|
raw = get(cfg, key)
|
|
176
|
-
Array(raw).map { |d|
|
|
144
|
+
Array(raw).map { |d| duration_to_seconds(d) }
|
|
177
145
|
end
|
|
178
146
|
|
|
179
|
-
|
|
147
|
+
# ---- duration coercion ----
|
|
148
|
+
|
|
149
|
+
def duration_to_seconds(val)
|
|
180
150
|
return nil if val.nil?
|
|
181
151
|
|
|
182
152
|
case val
|
|
183
153
|
when Integer
|
|
184
|
-
# Heuristic: extremely large integers are likely
|
|
185
|
-
|
|
154
|
+
# Heuristic: extremely large integers are likely **nanoseconds** from server
|
|
155
|
+
# (e.g., 30s => 30_000_000_000 ns). Convert ns → seconds.
|
|
156
|
+
return (val / 1_000_000_000.0).round if val >= 1_000_000_000
|
|
186
157
|
|
|
158
|
+
# otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
|
|
187
159
|
millis = Duration.to_millis(val, default_unit: :auto)
|
|
188
|
-
(millis
|
|
160
|
+
seconds_from_millis(millis)
|
|
189
161
|
when Float
|
|
190
162
|
millis = Duration.to_millis(val, default_unit: :auto)
|
|
191
|
-
(millis
|
|
163
|
+
seconds_from_millis(millis)
|
|
192
164
|
when String
|
|
193
|
-
|
|
194
|
-
|
|
165
|
+
# Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
|
|
166
|
+
millis = Duration.to_millis(val) # default_unit ignored when unit given
|
|
167
|
+
seconds_from_millis(millis)
|
|
195
168
|
else
|
|
196
|
-
return
|
|
169
|
+
return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
|
|
197
170
|
|
|
198
171
|
raise ArgumentError, "invalid duration: #{val.inspect}"
|
|
199
172
|
end
|
|
200
173
|
end
|
|
201
174
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
175
|
+
def seconds_from_millis(millis)
|
|
176
|
+
# Always round up to avoid zero-second waits when sub-second durations are provided.
|
|
177
|
+
[(millis / 1000.0).ceil, 1].max
|
|
178
|
+
end
|
|
205
179
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
180
|
+
def log_runtime_skip
|
|
181
|
+
Logging.info(
|
|
182
|
+
"Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
|
|
183
|
+
'Ensure it is pre-created via provisioning.',
|
|
184
|
+
tag: 'JetstreamBridge::Consumer'
|
|
185
|
+
)
|
|
186
|
+
end
|
|
210
187
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
end
|
|
188
|
+
def resolve_nc
|
|
189
|
+
return @jts.nc if @jts.respond_to?(:nc)
|
|
190
|
+
return @jts.instance_variable_get(:@nc) if @jts.instance_variable_defined?(:@nc)
|
|
191
|
+
|
|
192
|
+
return @cfg.mock_nats_client if @cfg.respond_to?(:mock_nats_client) && @cfg.mock_nats_client
|
|
193
|
+
|
|
194
|
+
nil
|
|
219
195
|
end
|
|
220
196
|
end
|
|
221
197
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
return skipped_stream_info unless config.auto_provision
|
|
41
|
+
|
|
42
|
+
# Ensure we have an active connection before querying stream info
|
|
43
|
+
connect_if_needed!
|
|
44
|
+
|
|
45
|
+
jts = Connection.jetstream
|
|
46
|
+
raise ConnectionNotEstablishedError, 'NATS connection not established' unless jts
|
|
47
|
+
|
|
48
|
+
stream_info_payload(jts.stream_info(config.stream_name))
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
stream_error_payload(e)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def measure_nats_rtt
|
|
54
|
+
nc = Connection.nc
|
|
55
|
+
return nil unless nc
|
|
56
|
+
|
|
57
|
+
# Prefer native RTT API when available (e.g., new NATS clients)
|
|
58
|
+
if nc.respond_to?(:rtt)
|
|
59
|
+
rtt_value = normalize_ms(nc.rtt)
|
|
60
|
+
return rtt_value if rtt_value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Fallback for clients without #rtt (nats-pure): measure ping/pong via flush
|
|
64
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
65
|
+
nc.flush(1)
|
|
66
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
67
|
+
normalize_ms(duration)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Logging.warn(
|
|
70
|
+
"Failed to measure NATS RTT: #{e.class} #{e.message}",
|
|
71
|
+
tag: 'JetstreamBridge'
|
|
72
|
+
)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_ms(value)
|
|
77
|
+
return nil if value.nil?
|
|
78
|
+
return nil unless value.respond_to?(:to_f)
|
|
79
|
+
|
|
80
|
+
numeric = value.to_f
|
|
81
|
+
# Heuristic: sub-1 values are likely seconds; convert them to ms for reporting
|
|
82
|
+
ms = numeric < 1 ? numeric * 1000 : numeric
|
|
83
|
+
ms.round(2)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def assign_config_option!(cfg, key, val)
|
|
87
|
+
setter = :"#{key}="
|
|
88
|
+
raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
|
|
89
|
+
|
|
90
|
+
cfg.public_send(setter, val)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stream_info_payload(info)
|
|
94
|
+
config_data = info.config
|
|
95
|
+
state_data = info.state
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
exists: true,
|
|
99
|
+
name: config.stream_name,
|
|
100
|
+
subjects: extract_field(config_data, :subjects),
|
|
101
|
+
messages: extract_field(state_data, :messages)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extract_field(data, key)
|
|
106
|
+
data.respond_to?(key) ? data.public_send(key) : data[key]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stream_error_payload(error)
|
|
110
|
+
{
|
|
111
|
+
exists: false,
|
|
112
|
+
name: config.stream_name,
|
|
113
|
+
error: "#{error.class}: #{error.message}"
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def skipped_stream_info
|
|
118
|
+
{
|
|
119
|
+
exists: nil,
|
|
120
|
+
name: config.stream_name,
|
|
121
|
+
skipped: true,
|
|
122
|
+
reason: 'auto_provision=false (skip $JS.API.STREAM.INFO)'
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|