jetstream_bridge 5.1.0 → 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 +51 -33
- 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 +2 -2
- 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
|
|
@@ -304,7 +304,7 @@ module JetstreamBridge
|
|
|
304
304
|
if verify_js
|
|
305
305
|
verify_jetstream!
|
|
306
306
|
if config_auto_provision
|
|
307
|
-
Topology.
|
|
307
|
+
Topology.provision!(@jts)
|
|
308
308
|
Logging.info(
|
|
309
309
|
'Topology ensured after connection (auto_provision=true).',
|
|
310
310
|
tag: 'JetstreamBridge::Connection'
|
|
@@ -461,7 +461,7 @@ module JetstreamBridge
|
|
|
461
461
|
|
|
462
462
|
# Re-ensure topology after reconnect when allowed
|
|
463
463
|
if config_auto_provision
|
|
464
|
-
Topology.
|
|
464
|
+
Topology.provision!(@jts)
|
|
465
465
|
else
|
|
466
466
|
Logging.info(
|
|
467
467
|
'Skipping topology provisioning after reconnect (auto_provision=false).',
|
|
@@ -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
|
data/lib/jetstream_bridge.rb
CHANGED
|
@@ -140,7 +140,7 @@ module JetstreamBridge
|
|
|
140
140
|
return if @connection_initialized
|
|
141
141
|
|
|
142
142
|
config.validate!
|
|
143
|
-
|
|
143
|
+
connect_and_provision!
|
|
144
144
|
@connection_initialized = true
|
|
145
145
|
Logging.info('JetStream Bridge started successfully', tag: 'JetstreamBridge')
|
|
146
146
|
end
|
|
@@ -197,10 +197,10 @@ module JetstreamBridge
|
|
|
197
197
|
config.use_dlq
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
-
# Establishes a connection and
|
|
200
|
+
# Establishes a connection and provisions stream topology.
|
|
201
201
|
#
|
|
202
202
|
# @return [Object] JetStream context
|
|
203
|
-
def
|
|
203
|
+
def connect_and_provision!
|
|
204
204
|
config.validate!
|
|
205
205
|
provision = config.auto_provision
|
|
206
206
|
Connection.connect!(verify_js: provision)
|
|
@@ -208,7 +208,7 @@ module JetstreamBridge
|
|
|
208
208
|
raise ConnectionNotEstablishedError, 'JetStream connection not available' unless jts
|
|
209
209
|
|
|
210
210
|
if provision
|
|
211
|
-
Provisioner.new(config: config).
|
|
211
|
+
Provisioner.new(config: config).provision_stream!(jts: jts)
|
|
212
212
|
else
|
|
213
213
|
Logging.info(
|
|
214
214
|
'auto_provision=false: skipping stream provisioning and JetStream account_info. ' \
|
|
@@ -222,16 +222,11 @@ module JetstreamBridge
|
|
|
222
222
|
|
|
223
223
|
# Provision stream/consumer using management credentials (out of band from runtime).
|
|
224
224
|
#
|
|
225
|
-
# @param
|
|
225
|
+
# @param provision_consumer [Boolean] Whether to create/align the consumer along with the stream.
|
|
226
226
|
# @return [Object] JetStream context
|
|
227
|
-
def provision!(
|
|
227
|
+
def provision!(provision_consumer: true)
|
|
228
228
|
config.validate!
|
|
229
|
-
Provisioner.new(config: config).
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# Backwards-compatible alias for the previous method name
|
|
233
|
-
def ensure_topology!
|
|
234
|
-
connect_and_ensure_stream!
|
|
229
|
+
Provisioner.new(config: config).provision!(provision_consumer: provision_consumer)
|
|
235
230
|
end
|
|
236
231
|
|
|
237
232
|
# Active health check for monitoring and readiness probes
|
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
|
+
version: 7.0.0
|
|
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-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- CHANGELOG.md
|
|
122
122
|
- LICENSE
|
|
123
123
|
- README.md
|
|
124
|
+
- docs/API.md
|
|
124
125
|
- docs/ARCHITECTURE.md
|
|
125
126
|
- docs/GETTING_STARTED.md
|
|
126
127
|
- docs/PRODUCTION.md
|
|
@@ -135,7 +136,9 @@ files:
|
|
|
135
136
|
- lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb
|
|
136
137
|
- lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb
|
|
137
138
|
- lib/jetstream_bridge.rb
|
|
139
|
+
- lib/jetstream_bridge/config_helpers.rb
|
|
138
140
|
- lib/jetstream_bridge/consumer/consumer.rb
|
|
141
|
+
- lib/jetstream_bridge/consumer/consumer_state.rb
|
|
139
142
|
- lib/jetstream_bridge/consumer/dlq_publisher.rb
|
|
140
143
|
- lib/jetstream_bridge/consumer/inbox/inbox_message.rb
|
|
141
144
|
- lib/jetstream_bridge/consumer/inbox/inbox_processor.rb
|