jetstream_bridge 5.0.2 → 7.0.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 +37 -0
- data/README.md +6 -3
- data/docs/API.md +395 -0
- data/docs/ARCHITECTURE.md +66 -4
- data/docs/GETTING_STARTED.md +72 -1
- data/docs/PRODUCTION.md +1 -1
- data/docs/RESTRICTED_PERMISSIONS.md +1 -1
- data/docs/TESTING.md +3 -3
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +29 -13
- data/lib/jetstream_bridge/config_helpers.rb +122 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +56 -27
- data/lib/jetstream_bridge/consumer/consumer_state.rb +58 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +12 -2
- data/lib/jetstream_bridge/consumer/pull_subscription_builder.rb +6 -6
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +44 -106
- data/lib/jetstream_bridge/core/connection.rb +56 -10
- data/lib/jetstream_bridge/core/duration.rb +30 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +1 -1
- data/lib/jetstream_bridge/models/outbox_event.rb +1 -1
- data/lib/jetstream_bridge/provisioner.rb +69 -13
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +35 -20
- data/lib/jetstream_bridge/publisher/publisher.rb +4 -4
- data/lib/jetstream_bridge/tasks/install.rake +2 -2
- data/lib/jetstream_bridge/topology/stream.rb +6 -1
- data/lib/jetstream_bridge/topology/topology.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +7 -12
- metadata +5 -2
|
@@ -51,50 +51,37 @@ module JetstreamBridge
|
|
|
51
51
|
def subscribe_without_verification!
|
|
52
52
|
# Manually create a pull subscription without calling consumer_info
|
|
53
53
|
# This bypasses the permission check in nats-pure's pull_subscribe
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
tag: 'JetstreamBridge::Consumer'
|
|
63
|
-
)
|
|
64
|
-
return @jts.pull_subscribe(filter_subject, @durable, stream: stream_name)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
raise JetstreamBridge::ConnectionError,
|
|
68
|
-
'Unable to create subscription without verification: NATS client not available'
|
|
54
|
+
create_subscription_with_fallback(
|
|
55
|
+
description: "pull subscription for consumer #{@durable} (stream=#{stream_name})",
|
|
56
|
+
primary_check: ->(nc) { nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe) },
|
|
57
|
+
primary_action: ->(nc) { build_pull_subscription(nc) },
|
|
58
|
+
fallback_name: :pull_subscribe,
|
|
59
|
+
fallback_available: -> { @jts.respond_to?(:pull_subscribe) },
|
|
60
|
+
fallback_action: -> { @jts.pull_subscribe(filter_subject, @durable, stream: stream_name) }
|
|
61
|
+
)
|
|
69
62
|
end
|
|
70
63
|
|
|
71
64
|
def subscribe_push!
|
|
72
65
|
# Push consumers deliver messages directly to a subscription subject
|
|
73
66
|
# No JetStream API calls needed - just subscribe to the delivery subject
|
|
74
|
-
nc = resolve_nc
|
|
75
67
|
delivery_subject = @cfg.push_delivery_subject
|
|
76
68
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return @jts.subscribe(delivery_subject)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
raise JetstreamBridge::ConnectionError,
|
|
97
|
-
'Unable to create push subscription: NATS client not available'
|
|
69
|
+
create_subscription_with_fallback(
|
|
70
|
+
description: "push subscription for consumer #{@durable} (stream=#{stream_name}, delivery=#{delivery_subject})",
|
|
71
|
+
primary_check: ->(nc) { nc.respond_to?(:subscribe) },
|
|
72
|
+
primary_action: lambda do |nc|
|
|
73
|
+
sub = nc.subscribe(delivery_subject)
|
|
74
|
+
Logging.info(
|
|
75
|
+
"Created push subscription for consumer #{@durable} " \
|
|
76
|
+
"(stream=#{stream_name}, delivery=#{delivery_subject})",
|
|
77
|
+
tag: 'JetstreamBridge::Consumer'
|
|
78
|
+
)
|
|
79
|
+
sub
|
|
80
|
+
end,
|
|
81
|
+
fallback_name: :subscribe,
|
|
82
|
+
fallback_available: -> { @jts.respond_to?(:subscribe) },
|
|
83
|
+
fallback_action: -> { @jts.subscribe(delivery_subject) }
|
|
84
|
+
)
|
|
98
85
|
end
|
|
99
86
|
|
|
100
87
|
private
|
|
@@ -107,8 +94,8 @@ module JetstreamBridge
|
|
|
107
94
|
deliver_policy: 'all',
|
|
108
95
|
max_deliver: JetstreamBridge.config.max_deliver,
|
|
109
96
|
# JetStream expects seconds (the client multiplies by nanoseconds).
|
|
110
|
-
ack_wait:
|
|
111
|
-
backoff:
|
|
97
|
+
ack_wait: Duration.to_seconds(JetstreamBridge.config.ack_wait),
|
|
98
|
+
backoff: Duration.normalize_list_to_seconds(JetstreamBridge.config.backoff)
|
|
112
99
|
}
|
|
113
100
|
|
|
114
101
|
# Add deliver_subject for push consumers
|
|
@@ -125,73 +112,6 @@ module JetstreamBridge
|
|
|
125
112
|
)
|
|
126
113
|
end
|
|
127
114
|
|
|
128
|
-
# ---- cfg access/normalization (struct-like or hash-like) ----
|
|
129
|
-
|
|
130
|
-
def get(cfg, key)
|
|
131
|
-
cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def sval(cfg, key)
|
|
135
|
-
v = get(cfg, key)
|
|
136
|
-
v = v.to_s if v.is_a?(Symbol)
|
|
137
|
-
v&.to_s&.downcase
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def ival(cfg, key)
|
|
141
|
-
v = get(cfg, key)
|
|
142
|
-
v.to_i
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Normalize duration-like field to **milliseconds** (Integer).
|
|
146
|
-
# Accepts:
|
|
147
|
-
# - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
|
|
148
|
-
# - Integers/Floats:
|
|
149
|
-
# * Server may return large integers in **nanoseconds** → detect and convert.
|
|
150
|
-
# * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
|
|
151
|
-
def d_secs(cfg, key)
|
|
152
|
-
raw = get(cfg, key)
|
|
153
|
-
duration_to_seconds(raw)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Normalize array of durations to integer milliseconds.
|
|
157
|
-
def darr_secs(cfg, key)
|
|
158
|
-
raw = get(cfg, key)
|
|
159
|
-
Array(raw).map { |d| duration_to_seconds(d) }
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# ---- duration coercion ----
|
|
163
|
-
|
|
164
|
-
def duration_to_seconds(val)
|
|
165
|
-
return nil if val.nil?
|
|
166
|
-
|
|
167
|
-
case val
|
|
168
|
-
when Integer
|
|
169
|
-
# Heuristic: extremely large integers are likely **nanoseconds** from server
|
|
170
|
-
# (e.g., 30s => 30_000_000_000 ns). Convert ns → seconds.
|
|
171
|
-
return (val / 1_000_000_000.0).round if val >= 1_000_000_000
|
|
172
|
-
|
|
173
|
-
# otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
|
|
174
|
-
millis = Duration.to_millis(val, default_unit: :auto)
|
|
175
|
-
seconds_from_millis(millis)
|
|
176
|
-
when Float
|
|
177
|
-
millis = Duration.to_millis(val, default_unit: :auto)
|
|
178
|
-
seconds_from_millis(millis)
|
|
179
|
-
when String
|
|
180
|
-
# Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
|
|
181
|
-
millis = Duration.to_millis(val) # default_unit ignored when unit given
|
|
182
|
-
seconds_from_millis(millis)
|
|
183
|
-
else
|
|
184
|
-
return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
|
|
185
|
-
|
|
186
|
-
raise ArgumentError, "invalid duration: #{val.inspect}"
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def seconds_from_millis(millis)
|
|
191
|
-
# Always round up to avoid zero-second waits when sub-second durations are provided.
|
|
192
|
-
[(millis / 1000.0).ceil, 1].max
|
|
193
|
-
end
|
|
194
|
-
|
|
195
115
|
def log_runtime_skip
|
|
196
116
|
Logging.info(
|
|
197
117
|
"Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
|
|
@@ -213,5 +133,23 @@ module JetstreamBridge
|
|
|
213
133
|
builder = PullSubscriptionBuilder.new(@jts, @durable, stream_name, filter_subject)
|
|
214
134
|
builder.build(nats_client)
|
|
215
135
|
end
|
|
136
|
+
|
|
137
|
+
def create_subscription_with_fallback(description:, primary_check:, primary_action:, fallback_name:,
|
|
138
|
+
fallback_available:, fallback_action:)
|
|
139
|
+
nc = resolve_nc
|
|
140
|
+
|
|
141
|
+
return primary_action.call(nc) if nc && primary_check.call(nc)
|
|
142
|
+
|
|
143
|
+
if fallback_available.call
|
|
144
|
+
Logging.info(
|
|
145
|
+
"Using #{fallback_name} fallback for #{description}",
|
|
146
|
+
tag: 'JetstreamBridge::Consumer'
|
|
147
|
+
)
|
|
148
|
+
return fallback_action.call
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
raise JetstreamBridge::ConnectionError,
|
|
152
|
+
"Unable to create #{description}: NATS client not available"
|
|
153
|
+
end
|
|
216
154
|
end
|
|
217
155
|
end
|
|
@@ -237,7 +237,36 @@ module JetstreamBridge
|
|
|
237
237
|
'NATS reconnected, refreshing JetStream context',
|
|
238
238
|
tag: 'JetstreamBridge::Connection'
|
|
239
239
|
)
|
|
240
|
-
|
|
240
|
+
|
|
241
|
+
# Retry JetStream context refresh with exponential backoff
|
|
242
|
+
max_attempts = 3
|
|
243
|
+
attempts = 0
|
|
244
|
+
success = false
|
|
245
|
+
|
|
246
|
+
while attempts < max_attempts && !success
|
|
247
|
+
attempts += 1
|
|
248
|
+
begin
|
|
249
|
+
refresh_jetstream_context
|
|
250
|
+
success = true
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
if attempts < max_attempts
|
|
253
|
+
delay = 0.5 * (2**(attempts - 1)) # 0.5s, 1s, 2s
|
|
254
|
+
Logging.warn(
|
|
255
|
+
"JetStream context refresh attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
256
|
+
"Retrying in #{delay}s...",
|
|
257
|
+
tag: 'JetstreamBridge::Connection'
|
|
258
|
+
)
|
|
259
|
+
sleep(delay)
|
|
260
|
+
else
|
|
261
|
+
Logging.error(
|
|
262
|
+
"Failed to refresh JetStream context after #{attempts} attempts. " \
|
|
263
|
+
'Will retry on next reconnect.',
|
|
264
|
+
tag: 'JetstreamBridge::Connection'
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
241
270
|
@reconnecting = false
|
|
242
271
|
end
|
|
243
272
|
|
|
@@ -275,7 +304,7 @@ module JetstreamBridge
|
|
|
275
304
|
if verify_js
|
|
276
305
|
verify_jetstream!
|
|
277
306
|
if config_auto_provision
|
|
278
|
-
Topology.
|
|
307
|
+
Topology.provision!(@jts)
|
|
279
308
|
Logging.info(
|
|
280
309
|
'Topology ensured after connection (auto_provision=true).',
|
|
281
310
|
tag: 'JetstreamBridge::Connection'
|
|
@@ -432,7 +461,7 @@ module JetstreamBridge
|
|
|
432
461
|
|
|
433
462
|
# Re-ensure topology after reconnect when allowed
|
|
434
463
|
if config_auto_provision
|
|
435
|
-
Topology.
|
|
464
|
+
Topology.provision!(@jts)
|
|
436
465
|
else
|
|
437
466
|
Logging.info(
|
|
438
467
|
'Skipping topology provisioning after reconnect (auto_provision=false).',
|
|
@@ -458,15 +487,21 @@ module JetstreamBridge
|
|
|
458
487
|
@last_reconnect_error = e
|
|
459
488
|
@last_reconnect_error_at = Time.now
|
|
460
489
|
@state = State::FAILED
|
|
461
|
-
|
|
490
|
+
|
|
491
|
+
# Clear JetStream context but keep NATS connection alive for retry
|
|
492
|
+
@jts = nil
|
|
493
|
+
|
|
494
|
+
# Invalidate health check cache to force re-check
|
|
495
|
+
@cached_health_status = false
|
|
496
|
+
@last_health_check = Time.now.to_i
|
|
497
|
+
|
|
462
498
|
Logging.error(
|
|
463
499
|
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
464
500
|
tag: 'JetstreamBridge::Connection'
|
|
465
501
|
)
|
|
466
502
|
|
|
467
|
-
#
|
|
468
|
-
|
|
469
|
-
@last_health_check = Time.now.to_i
|
|
503
|
+
# Re-raise so caller (on_reconnect handler) can retry
|
|
504
|
+
raise
|
|
470
505
|
end
|
|
471
506
|
|
|
472
507
|
# Expose for class-level helpers (not part of public API)
|
|
@@ -491,11 +526,22 @@ module JetstreamBridge
|
|
|
491
526
|
rescue StandardError
|
|
492
527
|
# ignore cleanup errors
|
|
493
528
|
end
|
|
494
|
-
|
|
495
|
-
|
|
529
|
+
|
|
530
|
+
# Only clear connection references if we're closing the connection
|
|
531
|
+
# When close_nc is false (e.g., during reconnect failures), keep @nc
|
|
532
|
+
# so the connection can recover when NATS auto-reconnects
|
|
533
|
+
if close_nc
|
|
534
|
+
@nc = nil
|
|
535
|
+
@jts = nil
|
|
536
|
+
@connected_at = nil
|
|
537
|
+
else
|
|
538
|
+
# Clear JetStream context but keep NATS connection reference
|
|
539
|
+
@jts = nil
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Always invalidate health check cache
|
|
496
543
|
@cached_health_status = nil
|
|
497
544
|
@last_health_check = nil
|
|
498
|
-
@connected_at = nil
|
|
499
545
|
end
|
|
500
546
|
|
|
501
547
|
def config_auto_provision
|
|
@@ -64,6 +64,31 @@ module JetstreamBridge
|
|
|
64
64
|
vals.map { |v| to_millis(v, default_unit: default_unit) }
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# Convert duration-like value to seconds (rounding up, min 1s).
|
|
68
|
+
#
|
|
69
|
+
# Retains the nanosecond heuristic used in SubscriptionManager:
|
|
70
|
+
# extremely large integers (>= 1_000_000_000) are treated as nanoseconds
|
|
71
|
+
# when default_unit is :auto.
|
|
72
|
+
def to_seconds(val, default_unit: :auto)
|
|
73
|
+
return nil if val.nil?
|
|
74
|
+
|
|
75
|
+
millis = if val.is_a?(Integer) && default_unit == :auto && val >= 1_000_000_000
|
|
76
|
+
to_millis(val, default_unit: :ns)
|
|
77
|
+
else
|
|
78
|
+
to_millis(val, default_unit: default_unit)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
seconds_from_millis(millis)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Normalize an array of durations into integer seconds.
|
|
85
|
+
def normalize_list_to_seconds(values, default_unit: :auto)
|
|
86
|
+
vals = Array(values)
|
|
87
|
+
return [] if vals.empty?
|
|
88
|
+
|
|
89
|
+
vals.map { |v| to_seconds(v, default_unit: default_unit) }
|
|
90
|
+
end
|
|
91
|
+
|
|
67
92
|
# --- internal helpers ---
|
|
68
93
|
|
|
69
94
|
def int_to_ms(num, default_unit:)
|
|
@@ -100,5 +125,10 @@ module JetstreamBridge
|
|
|
100
125
|
|
|
101
126
|
(num * mult).round
|
|
102
127
|
end
|
|
128
|
+
|
|
129
|
+
def seconds_from_millis(millis)
|
|
130
|
+
# Always round up to avoid zero-second waits when sub-second durations are provided.
|
|
131
|
+
[(millis / 1000.0).ceil, 1].max
|
|
132
|
+
end
|
|
103
133
|
end
|
|
104
134
|
end
|
|
@@ -11,7 +11,7 @@ end
|
|
|
11
11
|
module JetstreamBridge
|
|
12
12
|
if defined?(ActiveRecord::Base)
|
|
13
13
|
class InboxEvent < ActiveRecord::Base
|
|
14
|
-
self.table_name = '
|
|
14
|
+
self.table_name = 'jetstream_bridge_inbox_events'
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
17
|
# Safe column presence check that never boots a connection during class load.
|
|
@@ -11,7 +11,7 @@ end
|
|
|
11
11
|
module JetstreamBridge
|
|
12
12
|
if defined?(ActiveRecord::Base)
|
|
13
13
|
class OutboxEvent < ActiveRecord::Base
|
|
14
|
-
self.table_name = '
|
|
14
|
+
self.table_name = 'jetstream_bridge_outbox_events'
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
17
|
# Safe column presence check that never boots a connection during class load.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'logger'
|
|
3
4
|
require_relative 'topology/topology'
|
|
4
5
|
require_relative 'consumer/subscription_manager'
|
|
5
6
|
require_relative 'core/logging'
|
|
@@ -13,52 +14,107 @@ module JetstreamBridge
|
|
|
13
14
|
# deploy-time with admin credentials or during runtime when auto_provision
|
|
14
15
|
# is enabled.
|
|
15
16
|
class Provisioner
|
|
17
|
+
class << self
|
|
18
|
+
# Provision both directions (A->B and B->A) with shared defaults.
|
|
19
|
+
#
|
|
20
|
+
# @param app_a [String] First app name
|
|
21
|
+
# @param app_b [String] Second app name
|
|
22
|
+
# @param stream_name [String] Stream used for both directions
|
|
23
|
+
# @param nats_url [String] NATS connection URL
|
|
24
|
+
# @param logger [Logger] Logger used for progress output
|
|
25
|
+
# @param shared_config [Hash] Additional config applied to both directions
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def provision_bidirectional!(
|
|
29
|
+
app_a:,
|
|
30
|
+
app_b:,
|
|
31
|
+
stream_name: 'sync-stream',
|
|
32
|
+
nats_url: ENV.fetch('NATS_URL', 'nats://nats:4222'),
|
|
33
|
+
logger: Logger.new($stdout),
|
|
34
|
+
**shared_config
|
|
35
|
+
)
|
|
36
|
+
[
|
|
37
|
+
{ app_name: app_a, destination_app: app_b },
|
|
38
|
+
{ app_name: app_b, destination_app: app_a }
|
|
39
|
+
].each do |direction|
|
|
40
|
+
logger&.info "Provisioning #{direction[:app_name]} -> #{direction[:destination_app]}"
|
|
41
|
+
configure_direction(direction, stream_name, nats_url, logger, shared_config)
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
JetstreamBridge.startup!
|
|
45
|
+
new.provision!
|
|
46
|
+
ensure
|
|
47
|
+
JetstreamBridge.shutdown!
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def configure_direction(direction, stream_name, nats_url, logger, shared_config)
|
|
53
|
+
JetstreamBridge.configure do |cfg|
|
|
54
|
+
cfg.nats_urls = nats_url
|
|
55
|
+
cfg.app_name = direction[:app_name]
|
|
56
|
+
cfg.destination_app = direction[:destination_app]
|
|
57
|
+
cfg.stream_name = stream_name
|
|
58
|
+
cfg.auto_provision = true
|
|
59
|
+
cfg.use_outbox = false
|
|
60
|
+
cfg.use_inbox = false
|
|
61
|
+
cfg.logger = logger if logger
|
|
62
|
+
|
|
63
|
+
shared_config.each do |key, value|
|
|
64
|
+
setter = "#{key}="
|
|
65
|
+
cfg.public_send(setter, value) if cfg.respond_to?(setter)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
private :configure_direction
|
|
70
|
+
end
|
|
71
|
+
|
|
16
72
|
def initialize(config: JetstreamBridge.config)
|
|
17
73
|
@config = config
|
|
18
74
|
end
|
|
19
75
|
|
|
20
|
-
#
|
|
76
|
+
# Provision stream (and optionally consumer) with desired config.
|
|
21
77
|
#
|
|
22
78
|
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
23
|
-
# @param
|
|
79
|
+
# @param provision_consumer [Boolean] Whether to create/align the consumer too
|
|
24
80
|
# @return [Object] JetStream context used for provisioning
|
|
25
|
-
def
|
|
81
|
+
def provision!(jts: nil, provision_consumer: true)
|
|
26
82
|
js = jts || Connection.connect!(verify_js: true)
|
|
27
83
|
|
|
28
|
-
|
|
29
|
-
|
|
84
|
+
provision_stream!(jts: js)
|
|
85
|
+
provision_consumer!(jts: js) if provision_consumer
|
|
30
86
|
|
|
31
87
|
Logging.info(
|
|
32
|
-
"Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if
|
|
88
|
+
"Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if provision_consumer}",
|
|
33
89
|
tag: 'JetstreamBridge::Provisioner'
|
|
34
90
|
)
|
|
35
91
|
|
|
36
92
|
js
|
|
37
93
|
end
|
|
38
94
|
|
|
39
|
-
#
|
|
95
|
+
# Provision stream only.
|
|
40
96
|
#
|
|
41
97
|
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
42
98
|
# @return [Object] JetStream context used
|
|
43
|
-
def
|
|
99
|
+
def provision_stream!(jts: nil)
|
|
44
100
|
js = jts || Connection.connect!(verify_js: true)
|
|
45
|
-
Topology.
|
|
101
|
+
Topology.provision!(js)
|
|
46
102
|
Logging.info(
|
|
47
|
-
"Stream
|
|
103
|
+
"Stream provisioned: #{@config.stream_name}",
|
|
48
104
|
tag: 'JetstreamBridge::Provisioner'
|
|
49
105
|
)
|
|
50
106
|
js
|
|
51
107
|
end
|
|
52
108
|
|
|
53
|
-
#
|
|
109
|
+
# Provision durable consumer only.
|
|
54
110
|
#
|
|
55
111
|
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
56
112
|
# @return [Object] JetStream context used
|
|
57
|
-
def
|
|
113
|
+
def provision_consumer!(jts: nil)
|
|
58
114
|
js = jts || Connection.connect!(verify_js: true)
|
|
59
115
|
SubscriptionManager.new(js, @config.durable_name, @config).ensure_consumer!(force: true)
|
|
60
116
|
Logging.info(
|
|
61
|
-
"Consumer
|
|
117
|
+
"Consumer provisioned: #{@config.durable_name}",
|
|
62
118
|
tag: 'JetstreamBridge::Provisioner'
|
|
63
119
|
)
|
|
64
120
|
js
|
|
@@ -35,29 +35,15 @@ module JetstreamBridge
|
|
|
35
35
|
(record.respond_to?(:status) && record.status == 'sent')
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def
|
|
38
|
+
def record_publish_attempt(record, subject, envelope)
|
|
39
39
|
ActiveRecord::Base.transaction do
|
|
40
|
-
|
|
41
|
-
event_id = envelope['event_id'].to_s
|
|
42
|
-
|
|
43
|
-
attrs = {
|
|
44
|
-
event_id: event_id,
|
|
45
|
-
subject: subject,
|
|
46
|
-
payload: ModelUtils.json_dump(envelope),
|
|
47
|
-
headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
|
|
48
|
-
status: 'publishing',
|
|
49
|
-
last_error: nil
|
|
50
|
-
}
|
|
51
|
-
attrs[:attempts] = 1 + (record.attempts || 0) if record.respond_to?(:attempts)
|
|
52
|
-
attrs[:enqueued_at] = (record.enqueued_at || now) if record.respond_to?(:enqueued_at)
|
|
53
|
-
attrs[:updated_at] = now if record.respond_to?(:updated_at)
|
|
54
|
-
|
|
40
|
+
attrs = build_publish_attrs(record, subject, envelope)
|
|
55
41
|
ModelUtils.assign_known_attrs(record, attrs)
|
|
56
42
|
record.save!
|
|
57
43
|
end
|
|
58
44
|
end
|
|
59
45
|
|
|
60
|
-
def
|
|
46
|
+
def record_publish_success(record)
|
|
61
47
|
ActiveRecord::Base.transaction do
|
|
62
48
|
now = Time.now.utc
|
|
63
49
|
attrs = { status: 'sent' }
|
|
@@ -68,7 +54,7 @@ module JetstreamBridge
|
|
|
68
54
|
end
|
|
69
55
|
end
|
|
70
56
|
|
|
71
|
-
def
|
|
57
|
+
def record_publish_failure(record, message)
|
|
72
58
|
ActiveRecord::Base.transaction do
|
|
73
59
|
now = Time.now.utc
|
|
74
60
|
attrs = { status: 'failed', last_error: message }
|
|
@@ -78,13 +64,42 @@ module JetstreamBridge
|
|
|
78
64
|
end
|
|
79
65
|
end
|
|
80
66
|
|
|
81
|
-
def
|
|
67
|
+
def record_publish_exception(record, error)
|
|
82
68
|
return unless record
|
|
83
69
|
|
|
84
|
-
|
|
70
|
+
record_publish_failure(record, "#{error.class}: #{error.message}")
|
|
85
71
|
rescue StandardError => e
|
|
86
72
|
Logging.warn("Failed to persist outbox failure: #{e.class}: #{e.message}",
|
|
87
73
|
tag: 'JetstreamBridge::Publisher')
|
|
88
74
|
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def build_publish_attrs(record, subject, envelope)
|
|
79
|
+
now = Time.now.utc
|
|
80
|
+
event_id = envelope['event_id'].to_s
|
|
81
|
+
|
|
82
|
+
attrs = {
|
|
83
|
+
event_id: event_id,
|
|
84
|
+
subject: subject,
|
|
85
|
+
payload: ModelUtils.json_dump(envelope),
|
|
86
|
+
headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
|
|
87
|
+
status: 'publishing',
|
|
88
|
+
last_error: nil,
|
|
89
|
+
resource_type: envelope['resource_type'],
|
|
90
|
+
resource_id: envelope['resource_id'],
|
|
91
|
+
event_type: envelope['type'] || envelope['event_type']
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
assign_optional_publish_attrs(record, attrs, now)
|
|
95
|
+
attrs
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def assign_optional_publish_attrs(record, attrs, now)
|
|
99
|
+
attrs[:destination_app] = JetstreamBridge.config.destination_app if record.respond_to?(:destination_app=)
|
|
100
|
+
attrs[:attempts] = 1 + (record.attempts || 0) if record.respond_to?(:attempts)
|
|
101
|
+
attrs[:enqueued_at] = (record.enqueued_at || now) if record.respond_to?(:enqueued_at)
|
|
102
|
+
attrs[:updated_at] = now if record.respond_to?(:updated_at)
|
|
103
|
+
end
|
|
89
104
|
end
|
|
90
105
|
end
|
|
@@ -252,17 +252,17 @@ module JetstreamBridge
|
|
|
252
252
|
)
|
|
253
253
|
end
|
|
254
254
|
|
|
255
|
-
repo.
|
|
255
|
+
repo.record_publish_attempt(record, subject, envelope)
|
|
256
256
|
|
|
257
257
|
result = with_retries { publish_to_nats(subject, envelope) }
|
|
258
258
|
if result.success?
|
|
259
|
-
repo.
|
|
259
|
+
repo.record_publish_success(record)
|
|
260
260
|
else
|
|
261
|
-
repo.
|
|
261
|
+
repo.record_publish_failure(record, result.error&.message || 'Publish failed')
|
|
262
262
|
end
|
|
263
263
|
result
|
|
264
264
|
rescue StandardError => e
|
|
265
|
-
repo.
|
|
265
|
+
repo.record_publish_exception(record, e) if defined?(repo) && defined?(record)
|
|
266
266
|
Models::PublishResult.new(
|
|
267
267
|
success: false,
|
|
268
268
|
event_id: envelope['event_id'],
|
|
@@ -113,12 +113,12 @@ namespace :jetstream_bridge do
|
|
|
113
113
|
|
|
114
114
|
begin
|
|
115
115
|
provision_enabled = JetstreamBridge.config.auto_provision
|
|
116
|
-
jts = JetstreamBridge.
|
|
116
|
+
jts = JetstreamBridge.connect_and_provision!
|
|
117
117
|
|
|
118
118
|
if provision_enabled
|
|
119
119
|
puts '✓ Successfully connected to NATS'
|
|
120
120
|
puts '✓ JetStream is available'
|
|
121
|
-
puts '✓ Stream topology
|
|
121
|
+
puts '✓ Stream topology provisioned'
|
|
122
122
|
|
|
123
123
|
# Check if we can get account info
|
|
124
124
|
info = jts.account_info
|
|
@@ -174,7 +174,12 @@ module JetstreamBridge
|
|
|
174
174
|
|
|
175
175
|
# Only include mutable fields on update (subjects, storage). Never retention.
|
|
176
176
|
def apply_update(jts, name, subjects, storage: nil)
|
|
177
|
-
|
|
177
|
+
# Fetch existing stream config to preserve retention
|
|
178
|
+
info = jts.stream_info(name)
|
|
179
|
+
config_data = info.config
|
|
180
|
+
existing_retention = config_data.respond_to?(:retention) ? config_data.retention : config_data[:retention]
|
|
181
|
+
|
|
182
|
+
params = { name: name, subjects: subjects, retention: existing_retention }
|
|
178
183
|
params[:storage] = storage if storage
|
|
179
184
|
jts.update_stream(**params)
|
|
180
185
|
end
|