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
|
@@ -5,12 +5,11 @@ module JetstreamBridge
|
|
|
5
5
|
# Value object representing a NATS subject
|
|
6
6
|
#
|
|
7
7
|
# @example Creating a subject
|
|
8
|
-
# subject = Subject.source(
|
|
9
|
-
# subject.to_s # => "
|
|
8
|
+
# subject = Subject.source(app_name: "api", dest: "worker")
|
|
9
|
+
# subject.to_s # => "api.sync.worker"
|
|
10
10
|
#
|
|
11
11
|
# @example Parsing a subject string
|
|
12
|
-
# subject = Subject.parse("
|
|
13
|
-
# subject.env # => "production"
|
|
12
|
+
# subject = Subject.parse("api.sync.worker")
|
|
14
13
|
# subject.source_app # => "api"
|
|
15
14
|
# subject.dest_app # => "worker"
|
|
16
15
|
class Subject
|
|
@@ -31,16 +30,16 @@ module JetstreamBridge
|
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
# Factory methods
|
|
34
|
-
def self.source(
|
|
35
|
-
new("#{
|
|
33
|
+
def self.source(app_name:, dest:)
|
|
34
|
+
new("#{app_name}.sync.#{dest}")
|
|
36
35
|
end
|
|
37
36
|
|
|
38
|
-
def self.destination(
|
|
39
|
-
new("#{
|
|
37
|
+
def self.destination(source:, app_name:)
|
|
38
|
+
new("#{source}.sync.#{app_name}")
|
|
40
39
|
end
|
|
41
40
|
|
|
42
|
-
def self.dlq(
|
|
43
|
-
new("#{
|
|
41
|
+
def self.dlq(app_name:)
|
|
42
|
+
new("#{app_name}.sync.dlq")
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
# Parse a subject string into a Subject object with metadata
|
|
@@ -51,37 +50,30 @@ module JetstreamBridge
|
|
|
51
50
|
new(string)
|
|
52
51
|
end
|
|
53
52
|
|
|
54
|
-
# Get environment from subject (first token)
|
|
55
|
-
#
|
|
56
|
-
# @return [String, nil] Environment
|
|
57
|
-
def env
|
|
58
|
-
@tokens[0]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
53
|
# Get source application from subject
|
|
62
54
|
#
|
|
63
|
-
# For regular subjects: {
|
|
64
|
-
# For DLQ subjects: {
|
|
55
|
+
# For regular subjects: {source_app}.sync.{dest}
|
|
56
|
+
# For DLQ subjects: {app_name}.sync.dlq
|
|
65
57
|
#
|
|
66
58
|
# @return [String, nil] Source application
|
|
67
59
|
def source_app
|
|
68
|
-
@tokens[
|
|
60
|
+
@tokens[0]
|
|
69
61
|
end
|
|
70
62
|
|
|
71
63
|
# Get destination application from subject
|
|
72
64
|
#
|
|
73
65
|
# @return [String, nil] Destination application
|
|
74
66
|
def dest_app
|
|
75
|
-
@tokens[
|
|
67
|
+
@tokens[2]
|
|
76
68
|
end
|
|
77
69
|
|
|
78
70
|
# Check if this is a DLQ subject
|
|
79
71
|
#
|
|
80
|
-
# DLQ subjects follow the pattern: {
|
|
72
|
+
# DLQ subjects follow the pattern: {app}.sync.dlq
|
|
81
73
|
#
|
|
82
74
|
# @return [Boolean] True if this is a DLQ subject
|
|
83
75
|
def dlq?
|
|
84
|
-
@tokens.length ==
|
|
76
|
+
@tokens.length == 3 && @tokens[1] == 'sync' && @tokens[2] == 'dlq'
|
|
85
77
|
end
|
|
86
78
|
|
|
87
79
|
# Check if this subject matches a pattern
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'topology/topology'
|
|
4
|
+
require_relative 'consumer/subscription_manager'
|
|
5
|
+
require_relative 'core/logging'
|
|
6
|
+
require_relative 'core/config'
|
|
7
|
+
require_relative 'core/connection'
|
|
8
|
+
|
|
9
|
+
module JetstreamBridge
|
|
10
|
+
# Dedicated provisioning orchestrator to keep connection concerns separate.
|
|
11
|
+
#
|
|
12
|
+
# Handles creating/updating stream topology and consumers. Can be used at
|
|
13
|
+
# deploy-time with admin credentials or during runtime when auto_provision
|
|
14
|
+
# is enabled.
|
|
15
|
+
class Provisioner
|
|
16
|
+
def initialize(config: JetstreamBridge.config)
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Ensure stream (and optionally consumer) exist with desired config.
|
|
21
|
+
#
|
|
22
|
+
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
23
|
+
# @param ensure_consumer [Boolean] Whether to create/align the consumer too
|
|
24
|
+
# @return [Object] JetStream context used for provisioning
|
|
25
|
+
def ensure!(jts: nil, ensure_consumer: true)
|
|
26
|
+
js = jts || Connection.connect!(verify_js: true)
|
|
27
|
+
|
|
28
|
+
ensure_stream!(js)
|
|
29
|
+
ensure_consumer!(js) if ensure_consumer
|
|
30
|
+
|
|
31
|
+
Logging.info(
|
|
32
|
+
"Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if ensure_consumer}",
|
|
33
|
+
tag: 'JetstreamBridge::Provisioner'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
js
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Ensure stream only.
|
|
40
|
+
#
|
|
41
|
+
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
42
|
+
# @return [Object] JetStream context used
|
|
43
|
+
def ensure_stream!(jts: nil)
|
|
44
|
+
js = jts || Connection.connect!(verify_js: true)
|
|
45
|
+
Topology.ensure!(js)
|
|
46
|
+
Logging.info(
|
|
47
|
+
"Stream ensured: #{@config.stream_name}",
|
|
48
|
+
tag: 'JetstreamBridge::Provisioner'
|
|
49
|
+
)
|
|
50
|
+
js
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Ensure durable consumer only.
|
|
54
|
+
#
|
|
55
|
+
# @param jts [Object, nil] Existing JetStream context (optional)
|
|
56
|
+
# @return [Object] JetStream context used
|
|
57
|
+
def ensure_consumer!(jts: nil)
|
|
58
|
+
js = jts || Connection.connect!(verify_js: true)
|
|
59
|
+
SubscriptionManager.new(js, @config.durable_name, @config).ensure_consumer!(force: true)
|
|
60
|
+
Logging.info(
|
|
61
|
+
"Consumer ensured: #{@config.durable_name}",
|
|
62
|
+
tag: 'JetstreamBridge::Provisioner'
|
|
63
|
+
)
|
|
64
|
+
js
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require 'oj'
|
|
4
4
|
require 'securerandom'
|
|
5
|
+
require_relative '../core/connection'
|
|
5
6
|
require_relative '../core/logging'
|
|
6
7
|
require_relative '../core/config'
|
|
7
8
|
require_relative '../core/model_utils'
|
|
8
9
|
require_relative '../core/retry_strategy'
|
|
9
10
|
require_relative '../models/publish_result'
|
|
10
11
|
require_relative 'outbox_repository'
|
|
11
|
-
require_relative 'event_envelope_builder'
|
|
12
12
|
|
|
13
13
|
module JetstreamBridge
|
|
14
14
|
# Publishes events to NATS JetStream with reliability features.
|
|
15
15
|
#
|
|
16
|
-
# Publishes events to "{
|
|
16
|
+
# Publishes events to "{app}.sync.{dest}" subject pattern.
|
|
17
17
|
# Supports optional transactional outbox pattern for guaranteed delivery.
|
|
18
18
|
#
|
|
19
19
|
# @example Basic publishing
|
|
@@ -34,20 +34,19 @@ module JetstreamBridge
|
|
|
34
34
|
# JetstreamBridge.publish(event_type: "user.created", payload: { id: 1 })
|
|
35
35
|
#
|
|
36
36
|
class Publisher
|
|
37
|
-
# Initialize a new Publisher instance
|
|
37
|
+
# Initialize a new Publisher instance.
|
|
38
38
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# @
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
# Note: The NATS connection should already be established via JetstreamBridge.startup!
|
|
40
|
+
# or automatically on first use. This assumes the connection is already established.
|
|
41
|
+
#
|
|
42
|
+
# @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
|
|
43
|
+
# Defaults to PublisherRetryStrategy with exponential backoff.
|
|
44
|
+
# @raise [ConnectionError] If unable to get JetStream connection
|
|
45
|
+
def initialize(retry_strategy: nil)
|
|
46
|
+
@jts = Connection.jetstream
|
|
47
|
+
raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
|
|
48
|
+
|
|
49
49
|
@retry_strategy = retry_strategy || PublisherRetryStrategy.new
|
|
50
|
-
@envelope_builder = EventEnvelopeBuilder
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
# Publishes an event to NATS JetStream.
|
|
@@ -108,76 +107,16 @@ module JetstreamBridge
|
|
|
108
107
|
# logger.error "Failed to publish: #{result.error.message}"
|
|
109
108
|
# end
|
|
110
109
|
#
|
|
111
|
-
def publish(
|
|
110
|
+
def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
|
|
112
111
|
ensure_destination_app_configured!
|
|
113
112
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
resource_type: resource_type,
|
|
118
|
-
**
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
resolved_subject = subject || @config.source_subject
|
|
113
|
+
params = { event_or_hash: event_or_hash, resource_type: resource_type, event_type: event_type,
|
|
114
|
+
payload: payload, subject: subject, options: options }
|
|
115
|
+
envelope, resolved_subject = route_publish_params(params)
|
|
122
116
|
|
|
123
117
|
do_publish(resolved_subject, envelope)
|
|
124
|
-
rescue ArgumentError
|
|
125
|
-
# Re-raise validation errors
|
|
126
|
-
raise
|
|
127
|
-
rescue StandardError => e
|
|
128
|
-
# Return failure result for publishing errors
|
|
129
|
-
Models::PublishResult.new(
|
|
130
|
-
success: false,
|
|
131
|
-
event_id: envelope&.[]('event_id') || 'unknown',
|
|
132
|
-
subject: resolved_subject || 'unknown',
|
|
133
|
-
error: e
|
|
134
|
-
)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Publish a complete event envelope (advanced usage)
|
|
138
|
-
#
|
|
139
|
-
# Provides full control over the envelope structure. Use this when you need
|
|
140
|
-
# to manually construct the envelope or preserve all fields from an external source.
|
|
141
|
-
#
|
|
142
|
-
# @param envelope [Hash] Complete event envelope with all required fields
|
|
143
|
-
# @option envelope [String] 'event_id' Unique event identifier (required)
|
|
144
|
-
# @option envelope [Integer] 'schema_version' Schema version (required, typically 1)
|
|
145
|
-
# @option envelope [String] 'event_type' Event type (required)
|
|
146
|
-
# @option envelope [String] 'producer' Producer name (required)
|
|
147
|
-
# @option envelope [String] 'resource_type' Resource type (required)
|
|
148
|
-
# @option envelope [String] 'resource_id' Resource identifier (required)
|
|
149
|
-
# @option envelope [String] 'occurred_at' ISO8601 timestamp (required)
|
|
150
|
-
# @option envelope [String] 'trace_id' Trace identifier (required)
|
|
151
|
-
# @option envelope [Hash] 'payload' Event payload data (required)
|
|
152
|
-
# @param subject [String, nil] Optional NATS subject override
|
|
153
|
-
# @return [Models::PublishResult] Result object
|
|
154
|
-
#
|
|
155
|
-
# @raise [ArgumentError] If required envelope fields are missing
|
|
156
|
-
#
|
|
157
|
-
# @example Publishing a complete envelope
|
|
158
|
-
# envelope = {
|
|
159
|
-
# 'event_id' => SecureRandom.uuid,
|
|
160
|
-
# 'schema_version' => 1,
|
|
161
|
-
# 'event_type' => 'user.created',
|
|
162
|
-
# 'producer' => 'custom-producer',
|
|
163
|
-
# 'resource_type' => 'user',
|
|
164
|
-
# 'resource_id' => '123',
|
|
165
|
-
# 'occurred_at' => Time.now.utc.iso8601,
|
|
166
|
-
# 'trace_id' => 'trace-123',
|
|
167
|
-
# 'payload' => { id: 123, name: 'Alice' }
|
|
168
|
-
# }
|
|
169
|
-
#
|
|
170
|
-
# result = publisher.publish_envelope(envelope)
|
|
171
|
-
# puts "Published: #{result.event_id}"
|
|
172
|
-
def publish_envelope(envelope, subject: nil)
|
|
173
|
-
ensure_destination_app_configured!
|
|
174
|
-
validate_envelope!(envelope)
|
|
175
|
-
|
|
176
|
-
resolved_subject = subject || @config.source_subject
|
|
177
|
-
|
|
178
|
-
do_publish(resolved_subject, envelope)
|
|
179
|
-
rescue ArgumentError
|
|
180
|
-
# Re-raise validation errors for invalid envelope
|
|
118
|
+
rescue ArgumentError, JetstreamBridge::ConfigurationError
|
|
119
|
+
# Re-raise validation/configuration errors (e.g., missing destination_app)
|
|
181
120
|
raise
|
|
182
121
|
rescue StandardError => e
|
|
183
122
|
# Return failure result for publishing errors
|
|
@@ -199,7 +138,7 @@ module JetstreamBridge
|
|
|
199
138
|
# @return [Models::PublishResult] Result object
|
|
200
139
|
# @api private
|
|
201
140
|
def do_publish(subject, envelope)
|
|
202
|
-
if
|
|
141
|
+
if JetstreamBridge.config.use_outbox
|
|
203
142
|
publish_via_outbox(subject, envelope)
|
|
204
143
|
else
|
|
205
144
|
with_retries { publish_to_nats(subject, envelope) }
|
|
@@ -208,22 +147,48 @@ module JetstreamBridge
|
|
|
208
147
|
|
|
209
148
|
private
|
|
210
149
|
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
150
|
+
# Routes publish parameters to appropriate envelope builder
|
|
151
|
+
# @return [Array<Hash, String>] tuple of [envelope, subject]
|
|
152
|
+
def route_publish_params(params)
|
|
153
|
+
if structured_params?(params)
|
|
154
|
+
build_from_structured_params(params)
|
|
155
|
+
elsif keyword_or_hash_params?(params)
|
|
156
|
+
build_from_keyword_or_hash(params)
|
|
157
|
+
else
|
|
158
|
+
raise ArgumentError, 'Either provide (resource_type:, event_type:, payload:) or an event hash'
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def structured_params?(params)
|
|
163
|
+
params[:resource_type] && params[:event_type] && params[:payload]
|
|
164
|
+
end
|
|
215
165
|
|
|
216
|
-
|
|
166
|
+
def keyword_or_hash_params?(params)
|
|
167
|
+
params[:event_type] || params[:payload] || params[:event_or_hash].is_a?(Hash)
|
|
168
|
+
end
|
|
217
169
|
|
|
218
|
-
|
|
170
|
+
def build_from_structured_params(params)
|
|
171
|
+
envelope = build_envelope(params[:resource_type], params[:event_type], params[:payload], params[:options])
|
|
172
|
+
resolved_subject = params[:subject] || JetstreamBridge.config.source_subject
|
|
173
|
+
[envelope, resolved_subject]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_from_keyword_or_hash(params)
|
|
177
|
+
envelope = if params[:event_or_hash].is_a?(Hash)
|
|
178
|
+
normalize_envelope(params[:event_or_hash], params[:options])
|
|
179
|
+
else
|
|
180
|
+
build_from_keywords(params[:event_type], params[:payload], params[:options])
|
|
181
|
+
end
|
|
219
182
|
|
|
220
|
-
|
|
183
|
+
resolved_subject = params[:subject] || params[:options][:subject] || JetstreamBridge.config.source_subject
|
|
184
|
+
[envelope, resolved_subject]
|
|
221
185
|
end
|
|
222
186
|
|
|
223
|
-
def
|
|
224
|
-
|
|
187
|
+
def build_from_keywords(event_type, payload, options)
|
|
188
|
+
raise ArgumentError, 'event_type is required' unless event_type
|
|
189
|
+
raise ArgumentError, 'payload is required' unless payload
|
|
225
190
|
|
|
226
|
-
|
|
191
|
+
normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
|
|
227
192
|
end
|
|
228
193
|
|
|
229
194
|
def publish_to_nats(subject, envelope)
|
|
@@ -260,7 +225,7 @@ module JetstreamBridge
|
|
|
260
225
|
|
|
261
226
|
# ---- Outbox path ----
|
|
262
227
|
def publish_via_outbox(subject, envelope)
|
|
263
|
-
klass = ModelUtils.constantize(
|
|
228
|
+
klass = ModelUtils.constantize(JetstreamBridge.config.outbox_model)
|
|
264
229
|
|
|
265
230
|
unless ModelUtils.ar_class?(klass)
|
|
266
231
|
Logging.warn(
|
|
@@ -317,5 +282,69 @@ module JetstreamBridge
|
|
|
317
282
|
)
|
|
318
283
|
raise
|
|
319
284
|
end
|
|
285
|
+
|
|
286
|
+
def build_envelope(resource_type, event_type, payload, options = {})
|
|
287
|
+
{
|
|
288
|
+
'event_id' => options[:event_id] || SecureRandom.uuid,
|
|
289
|
+
'schema_version' => 1,
|
|
290
|
+
'event_type' => event_type,
|
|
291
|
+
'producer' => JetstreamBridge.config.app_name,
|
|
292
|
+
'resource_id' => extract_resource_id(payload),
|
|
293
|
+
'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
|
|
294
|
+
'trace_id' => options[:trace_id] || SecureRandom.hex(8),
|
|
295
|
+
'resource_type' => resource_type,
|
|
296
|
+
'payload' => payload
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Normalize a hash to match envelope structure, allowing partial envelopes
|
|
301
|
+
def normalize_envelope(hash, options = {})
|
|
302
|
+
hash = hash.transform_keys(&:to_s)
|
|
303
|
+
infer_resource_type_if_needed!(hash)
|
|
304
|
+
|
|
305
|
+
{
|
|
306
|
+
'event_id' => envelope_event_id(hash, options),
|
|
307
|
+
'schema_version' => hash['schema_version'] || 1,
|
|
308
|
+
'event_type' => hash['event_type'] || raise(ArgumentError, 'event_type is required'),
|
|
309
|
+
'producer' => hash['producer'] || JetstreamBridge.config.app_name,
|
|
310
|
+
'resource_id' => hash['resource_id'] || extract_resource_id(hash['payload']),
|
|
311
|
+
'occurred_at' => envelope_occurred_at(hash, options),
|
|
312
|
+
'trace_id' => envelope_trace_id(hash, options),
|
|
313
|
+
'resource_type' => hash['resource_type'] || 'event',
|
|
314
|
+
'payload' => hash['payload'] || raise(ArgumentError, 'payload is required')
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def infer_resource_type_if_needed!(hash)
|
|
319
|
+
return unless hash['event_type'] && hash['payload'] && !hash['resource_type']
|
|
320
|
+
|
|
321
|
+
# Try to infer from dot notation (e.g., 'user.created' -> 'user')
|
|
322
|
+
parts = hash['event_type'].split('.')
|
|
323
|
+
hash['resource_type'] = parts[0] if parts.size > 1
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def envelope_event_id(hash, options)
|
|
327
|
+
hash['event_id'] || options[:event_id] || SecureRandom.uuid
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def envelope_occurred_at(hash, options)
|
|
331
|
+
hash['occurred_at'] || (options[:occurred_at] || Time.now.utc).iso8601
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def envelope_trace_id(hash, options)
|
|
335
|
+
hash['trace_id'] || options[:trace_id] || SecureRandom.hex(8)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def extract_resource_id(payload)
|
|
339
|
+
return '' unless payload
|
|
340
|
+
|
|
341
|
+
payload = payload.transform_keys(&:to_s) if payload.respond_to?(:transform_keys)
|
|
342
|
+
(payload['id'] || payload[:id] || payload['resource_id'] || payload[:resource_id]).to_s
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def ensure_destination_app_configured!
|
|
346
|
+
# Leverage subject builder to enforce required components and surface consistent error messaging.
|
|
347
|
+
JetstreamBridge.config.source_subject
|
|
348
|
+
end
|
|
320
349
|
end
|
|
321
350
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../core/model_codec_setup'
|
|
4
4
|
require_relative '../core/logging'
|
|
5
|
+
require_relative '../core/connection'
|
|
5
6
|
|
|
6
7
|
module JetstreamBridge
|
|
7
8
|
module Rails
|
|
@@ -37,7 +38,8 @@ module JetstreamBridge
|
|
|
37
38
|
return
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
JetstreamBridge.
|
|
41
|
+
JetstreamBridge.config.validate!
|
|
42
|
+
JetstreamBridge.startup!
|
|
41
43
|
log_started!
|
|
42
44
|
log_development_connection_details! if rails_development?
|
|
43
45
|
register_shutdown_hook!
|
|
@@ -103,12 +105,7 @@ module JetstreamBridge
|
|
|
103
105
|
end
|
|
104
106
|
|
|
105
107
|
def log_development_connection_details!
|
|
106
|
-
|
|
107
|
-
JetstreamBridge.health
|
|
108
|
-
rescue StandardError
|
|
109
|
-
nil
|
|
110
|
-
end
|
|
111
|
-
conn_state = health&.dig(:connection, :state) || 'unknown'
|
|
108
|
+
conn_state = JetstreamBridge::Connection.instance.state
|
|
112
109
|
active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
|
|
113
110
|
active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
|
|
114
111
|
active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
|
|
@@ -119,7 +116,7 @@ module JetstreamBridge
|
|
|
119
116
|
def register_shutdown_hook!
|
|
120
117
|
return if @shutdown_hook_registered
|
|
121
118
|
|
|
122
|
-
at_exit { JetstreamBridge.
|
|
119
|
+
at_exit { JetstreamBridge.shutdown! }
|
|
123
120
|
@shutdown_hook_registered = true
|
|
124
121
|
end
|
|
125
122
|
|
|
@@ -7,7 +7,7 @@ module JetstreamBridge
|
|
|
7
7
|
#
|
|
8
8
|
# This Railtie integrates JetStream Bridge with the Rails application lifecycle:
|
|
9
9
|
# - Configuration: Logger is configured early in the Rails boot process
|
|
10
|
-
# - Startup: Connection is established after user initializers load (
|
|
10
|
+
# - Startup: Connection is established after user initializers load (explicit startup!)
|
|
11
11
|
# - Shutdown: Connection is closed when Rails shuts down (at_exit hook)
|
|
12
12
|
# - Restart: Puma/Unicorn workers get fresh connections on fork
|
|
13
13
|
#
|
|
@@ -33,11 +33,10 @@ module JetstreamBridge
|
|
|
33
33
|
console do
|
|
34
34
|
::Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
|
|
35
35
|
::Rails.logger.info '[JetStream Bridge] Console helpers available:'
|
|
36
|
-
::Rails.logger.info ' JetstreamBridge.
|
|
37
|
-
::Rails.logger.info ' JetstreamBridge.healthy? - Simple boolean health check'
|
|
36
|
+
::Rails.logger.info ' JetstreamBridge.health_check - Check connection status'
|
|
38
37
|
::Rails.logger.info ' JetstreamBridge.stream_info - View stream details'
|
|
39
38
|
::Rails.logger.info ' JetstreamBridge.connected? - Check if connected'
|
|
40
|
-
::Rails.logger.info ' JetstreamBridge.
|
|
39
|
+
::Rails.logger.info ' JetstreamBridge.shutdown! - Gracefully disconnect'
|
|
41
40
|
::Rails.logger.info ' JetstreamBridge.reconnect! - Reconnect (useful after configuration changes)'
|
|
42
41
|
end
|
|
43
42
|
|
|
@@ -36,7 +36,7 @@ namespace :jetstream_bridge do
|
|
|
36
36
|
|
|
37
37
|
if health[:config]
|
|
38
38
|
puts "\nConfiguration:"
|
|
39
|
-
puts "
|
|
39
|
+
puts " Stream: #{health[:config][:stream_name]}"
|
|
40
40
|
puts " App Name: #{health[:config][:app_name]}"
|
|
41
41
|
puts " Destination: #{health[:config][:destination_app] || 'NOT SET'}"
|
|
42
42
|
puts " Outbox: #{health[:config][:use_outbox] ? 'Enabled' : 'Disabled'}"
|
|
@@ -62,6 +62,7 @@ namespace :jetstream_bridge do
|
|
|
62
62
|
JetstreamBridge.config.validate!
|
|
63
63
|
puts '✓ Configuration is valid'
|
|
64
64
|
puts "\nCurrent settings:"
|
|
65
|
+
puts " Stream: #{JetstreamBridge.config.stream_name}"
|
|
65
66
|
puts " App Name: #{JetstreamBridge.config.app_name}"
|
|
66
67
|
puts " Destination: #{JetstreamBridge.config.destination_app}"
|
|
67
68
|
puts " Stream: #{JetstreamBridge.config.stream_name}"
|
|
@@ -79,27 +80,73 @@ namespace :jetstream_bridge do
|
|
|
79
80
|
JetstreamBridge::DebugHelper.debug_info
|
|
80
81
|
end
|
|
81
82
|
|
|
83
|
+
desc 'Provision stream and consumer (requires JetStream admin permissions)'
|
|
84
|
+
task provision: :environment do
|
|
85
|
+
puts '[jetstream_bridge] Provisioning JetStream stream and consumer...'
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
provisioner = JetstreamBridge::Provisioner.new
|
|
89
|
+
provisioner.ensure!(ensure_consumer: true)
|
|
90
|
+
puts "✓ Provisioned stream=#{JetstreamBridge.config.stream_name} consumer=#{JetstreamBridge.config.durable_name}"
|
|
91
|
+
exit 0
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
puts "✗ Provisioning failed: #{e.message}"
|
|
94
|
+
exit 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
82
98
|
desc 'Test connection to NATS'
|
|
83
99
|
task test_connection: :environment do
|
|
84
100
|
puts '[jetstream_bridge] Testing NATS connection...'
|
|
85
101
|
|
|
102
|
+
permission_violation = lambda do |err|
|
|
103
|
+
current = err
|
|
104
|
+
while current
|
|
105
|
+
message = current.message.to_s
|
|
106
|
+
return true if message.include?('Permissions Violation for Publish to "$JS.API')
|
|
107
|
+
return true if message.match?(/permission(?:s)? violation/i)
|
|
108
|
+
|
|
109
|
+
current = current.cause if current.respond_to?(:cause)
|
|
110
|
+
end
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
86
114
|
begin
|
|
115
|
+
provision_enabled = JetstreamBridge.config.auto_provision
|
|
87
116
|
jts = JetstreamBridge.connect_and_ensure_stream!
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
|
|
118
|
+
if provision_enabled
|
|
119
|
+
puts '✓ Successfully connected to NATS'
|
|
120
|
+
puts '✓ JetStream is available'
|
|
121
|
+
puts '✓ Stream topology ensured'
|
|
122
|
+
|
|
123
|
+
# Check if we can get account info
|
|
124
|
+
info = jts.account_info
|
|
125
|
+
puts "\nAccount Info:"
|
|
126
|
+
puts " Memory: #{info.memory}"
|
|
127
|
+
puts " Storage: #{info.storage}"
|
|
128
|
+
puts " Streams: #{info.streams}"
|
|
129
|
+
puts " Consumers: #{info.consumers}"
|
|
130
|
+
else
|
|
131
|
+
nc = jts.nc if jts.respond_to?(:nc)
|
|
132
|
+
nc ||= JetstreamBridge::Connection.nc
|
|
133
|
+
nc&.flush(0.5)
|
|
134
|
+
|
|
135
|
+
puts '✓ Successfully connected to NATS (ping-only: auto_provision=false)'
|
|
136
|
+
puts '✓ JetStream client initialized (skipped $JS.API.* calls)'
|
|
137
|
+
puts "✓ Stream provisioning/verification skipped for '#{JetstreamBridge.config.stream_name}' " \
|
|
138
|
+
'(assumes pre-provisioned via admin credentials)'
|
|
139
|
+
end
|
|
99
140
|
|
|
100
141
|
exit 0
|
|
101
142
|
rescue StandardError => e
|
|
102
143
|
puts "✗ Connection failed: #{e.message}"
|
|
144
|
+
if permission_violation.call(e)
|
|
145
|
+
puts "\nHint: current NATS credentials cannot call JetStream API subjects ($JS.API.*). " \
|
|
146
|
+
'Set config.auto_provision = false and pre-provision using ' \
|
|
147
|
+
'`bundle exec rake jetstream_bridge:provision` with admin credentials. ' \
|
|
148
|
+
'See docs/RESTRICTED_PERMISSIONS.md.'
|
|
149
|
+
end
|
|
103
150
|
puts "\nBacktrace:" if ENV['VERBOSE']
|
|
104
151
|
puts e.backtrace.first(10).map { |line| " #{line}" }.join("\n") if ENV['VERBOSE']
|
|
105
152
|
exit 1
|
|
@@ -6,13 +6,8 @@ require_relative 'stream'
|
|
|
6
6
|
|
|
7
7
|
module JetstreamBridge
|
|
8
8
|
class Topology
|
|
9
|
-
def self.ensure!(jts
|
|
9
|
+
def self.ensure!(jts)
|
|
10
10
|
cfg = JetstreamBridge.config
|
|
11
|
-
if cfg.disable_js_api && !force
|
|
12
|
-
Logging.info('Topology ensure skipped (JS API disabled).', tag: 'JetstreamBridge::Topology')
|
|
13
|
-
return
|
|
14
|
-
end
|
|
15
|
-
|
|
16
11
|
subjects = [cfg.source_subject, cfg.destination_subject]
|
|
17
12
|
subjects << cfg.dlq_subject if cfg.use_dlq
|
|
18
13
|
Stream.ensure!(jts, cfg.stream_name, subjects)
|