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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +338 -87
  3. data/README.md +3 -13
  4. data/docs/GETTING_STARTED.md +8 -12
  5. data/docs/PRODUCTION.md +13 -35
  6. data/docs/RESTRICTED_PERMISSIONS.md +525 -0
  7. data/docs/TESTING.md +33 -22
  8. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
  10. data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
  11. data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
  12. data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
  13. data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
  14. data/lib/jetstream_bridge/core/config.rb +32 -161
  15. data/lib/jetstream_bridge/core/connection.rb +508 -0
  16. data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
  17. data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
  18. data/lib/jetstream_bridge/core.rb +2 -0
  19. data/lib/jetstream_bridge/models/subject.rb +15 -23
  20. data/lib/jetstream_bridge/provisioner.rb +67 -0
  21. data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
  22. data/lib/jetstream_bridge/rails/integration.rb +5 -8
  23. data/lib/jetstream_bridge/rails/railtie.rb +3 -4
  24. data/lib/jetstream_bridge/tasks/install.rake +59 -12
  25. data/lib/jetstream_bridge/topology/topology.rb +1 -6
  26. data/lib/jetstream_bridge/version.rb +1 -1
  27. data/lib/jetstream_bridge.rb +345 -202
  28. metadata +8 -8
  29. data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
  30. data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
  31. data/lib/jetstream_bridge/core/health_checker.rb +0 -184
  32. data/lib/jetstream_bridge/facade.rb +0 -212
  33. 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
- return if @cfg.disable_js_api
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
- if @cfg.disable_js_api
33
- Logging.info("JS API disabled; assuming consumer #{@durable} exists", tag: 'JetstreamBridge::Consumer')
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
- info = consumer_info_or_nil
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
- opts = { stream: stream_name }
52
- if @cfg.disable_js_api
53
- opts[:bind] = true
54
- else
55
- opts[:config] = desired_consumer_cfg
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
- @jts.pull_subscribe(
59
- filter_subject,
60
- @durable,
61
- **opts
62
- )
63
- end
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
- Logging.warn(
84
- "Consumer #{@durable} config mismatch (filter=#{filter_subject}) diff=#{diffs}",
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 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) }
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
- # ---- duration coercion ----
168
-
169
- def nanos(cfg, key)
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
- duration_to_nanos(raw)
138
+ duration_to_seconds(raw)
172
139
  end
173
140
 
174
- def nanos_arr(cfg, key)
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| duration_to_nanos(d) }
144
+ Array(raw).map { |d| duration_to_seconds(d) }
177
145
  end
178
146
 
179
- def duration_to_nanos(val)
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 already nanoseconds
185
- return val if val >= 1_000_000_000 # >= 1s in nanos
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 * 1_000_000).to_i
160
+ seconds_from_millis(millis)
189
161
  when Float
190
162
  millis = Duration.to_millis(val, default_unit: :auto)
191
- (millis * 1_000_000).to_i
163
+ seconds_from_millis(millis)
192
164
  when String
193
- millis = Duration.to_millis(val) # unit-aware
194
- (millis * 1_000_000).to_i
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 duration_to_nanos(val.to_f) if val.respond_to?(:to_f)
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
- # Legacy helper used in specs; kept for backward compatibility.
203
- def duration_to_seconds(val)
204
- return nil if val.nil?
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
- 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
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
- (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
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