jetstream_bridge 5.1.0 → 7.0.1

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +6 -3
  4. data/docs/API.md +395 -0
  5. data/docs/ARCHITECTURE.md +123 -171
  6. data/docs/GETTING_STARTED.md +72 -1
  7. data/docs/PRODUCTION.md +10 -3
  8. data/docs/RESTRICTED_PERMISSIONS.md +7 -14
  9. data/docs/TESTING.md +3 -3
  10. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +29 -13
  11. data/lib/jetstream_bridge/config_helpers/lifecycle.rb +34 -0
  12. data/lib/jetstream_bridge/config_helpers.rb +118 -0
  13. data/lib/jetstream_bridge/consumer/consumer.rb +131 -41
  14. data/lib/jetstream_bridge/consumer/consumer_state.rb +58 -0
  15. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +12 -2
  16. data/lib/jetstream_bridge/consumer/pull_subscription_builder.rb +6 -6
  17. data/lib/jetstream_bridge/consumer/subscription_manager.rb +72 -110
  18. data/lib/jetstream_bridge/core/config.rb +31 -0
  19. data/lib/jetstream_bridge/core/connection.rb +97 -31
  20. data/lib/jetstream_bridge/core/consumer_mode_resolver.rb +64 -0
  21. data/lib/jetstream_bridge/core/duration.rb +30 -0
  22. data/lib/jetstream_bridge/models/inbox_event.rb +1 -1
  23. data/lib/jetstream_bridge/models/outbox_event.rb +1 -1
  24. data/lib/jetstream_bridge/provisioner.rb +108 -13
  25. data/lib/jetstream_bridge/publisher/outbox_repository.rb +35 -20
  26. data/lib/jetstream_bridge/publisher/publisher.rb +4 -4
  27. data/lib/jetstream_bridge/tasks/install.rake +2 -2
  28. data/lib/jetstream_bridge/topology/stream.rb +6 -1
  29. data/lib/jetstream_bridge/topology/topology.rb +1 -1
  30. data/lib/jetstream_bridge/version.rb +1 -1
  31. data/lib/jetstream_bridge.rb +8 -12
  32. metadata +7 -2
@@ -1,10 +1,12 @@
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'
6
7
  require_relative 'core/config'
7
8
  require_relative 'core/connection'
9
+ require_relative 'core/consumer_mode_resolver'
8
10
 
9
11
  module JetstreamBridge
10
12
  # Dedicated provisioning orchestrator to keep connection concerns separate.
@@ -13,52 +15,145 @@ module JetstreamBridge
13
15
  # deploy-time with admin credentials or during runtime when auto_provision
14
16
  # is enabled.
15
17
  class Provisioner
18
+ class << self
19
+ # Provision both directions (A->B and B->A) with shared defaults.
20
+ #
21
+ # @param app_a [String] First app name
22
+ # @param app_b [String] Second app name
23
+ # @param stream_name [String] Stream used for both directions
24
+ # @param nats_url [String] NATS connection URL
25
+ # @param logger [Logger] Logger used for progress output
26
+ # @param shared_config [Hash] Additional config applied to both directions
27
+ # @param consumer_modes [Hash,nil] Per-app consumer modes { 'system_a' => :pull, 'system_b' => :push }
28
+ # @param consumer_mode [Symbol] Legacy/shared consumer mode for both directions (overridden by consumer_modes)
29
+ #
30
+ # @return [void]
31
+ def provision_bidirectional!(
32
+ app_a:,
33
+ app_b:,
34
+ stream_name: 'sync-stream',
35
+ nats_url: ENV.fetch('NATS_URL', 'nats://nats:4222'),
36
+ logger: Logger.new($stdout),
37
+ consumer_modes: nil,
38
+ consumer_mode: :pull,
39
+ **shared_config
40
+ )
41
+ modes = build_consumer_mode_map(app_a, app_b, consumer_modes, consumer_mode)
42
+
43
+ [
44
+ { app_name: app_a, destination_app: app_b },
45
+ { app_name: app_b, destination_app: app_a }
46
+ ].each do |direction|
47
+ direction_mode = modes[direction[:app_name]] || consumer_mode
48
+ logger&.info "Provisioning #{direction[:app_name]} -> #{direction[:destination_app]}"
49
+ configure_direction(
50
+ direction,
51
+ stream_name: stream_name,
52
+ nats_url: nats_url,
53
+ logger: logger,
54
+ consumer_mode: direction_mode,
55
+ shared_config: shared_config
56
+ )
57
+
58
+ begin
59
+ JetstreamBridge.startup!
60
+ new.provision!
61
+ ensure
62
+ JetstreamBridge.shutdown!
63
+ end
64
+ end
65
+ end
66
+
67
+ def build_consumer_mode_map(app_a, app_b, consumer_modes, fallback_mode)
68
+ app_a_key = app_a.to_s
69
+ app_b_key = app_b.to_s
70
+ normalized_fallback = ConsumerModeResolver.normalize(fallback_mode)
71
+
72
+ if consumer_modes
73
+ normalized = consumer_modes.transform_keys(&:to_s).transform_values do |v|
74
+ ConsumerModeResolver.normalize(v)
75
+ end
76
+ normalized[app_a_key] ||= normalized_fallback
77
+ normalized[app_b_key] ||= normalized_fallback
78
+ return normalized
79
+ end
80
+
81
+ {
82
+ app_a_key => ConsumerModeResolver.resolve(app_name: app_a_key, fallback: normalized_fallback),
83
+ app_b_key => ConsumerModeResolver.resolve(app_name: app_b_key, fallback: normalized_fallback)
84
+ }
85
+ end
86
+ private :build_consumer_mode_map
87
+
88
+ def configure_direction(direction, stream_name:, nats_url:, logger:, consumer_mode:, shared_config:)
89
+ JetstreamBridge.configure do |cfg|
90
+ cfg.nats_urls = nats_url
91
+ cfg.app_name = direction[:app_name]
92
+ cfg.destination_app = direction[:destination_app]
93
+ cfg.stream_name = stream_name
94
+ cfg.auto_provision = true
95
+ cfg.use_outbox = false
96
+ cfg.use_inbox = false
97
+ cfg.logger = logger if logger
98
+ cfg.consumer_mode = consumer_mode
99
+
100
+ shared_config.each do |key, value|
101
+ next if key.to_sym == :consumer_mode
102
+
103
+ setter = "#{key}="
104
+ cfg.public_send(setter, value) if cfg.respond_to?(setter)
105
+ end
106
+ end
107
+ end
108
+ private :configure_direction
109
+ end
110
+
16
111
  def initialize(config: JetstreamBridge.config)
17
112
  @config = config
18
113
  end
19
114
 
20
- # Ensure stream (and optionally consumer) exist with desired config.
115
+ # Provision stream (and optionally consumer) with desired config.
21
116
  #
22
117
  # @param jts [Object, nil] Existing JetStream context (optional)
23
- # @param ensure_consumer [Boolean] Whether to create/align the consumer too
118
+ # @param provision_consumer [Boolean] Whether to create/align the consumer too
24
119
  # @return [Object] JetStream context used for provisioning
25
- def ensure!(jts: nil, ensure_consumer: true)
120
+ def provision!(jts: nil, provision_consumer: true)
26
121
  js = jts || Connection.connect!(verify_js: true)
27
122
 
28
- ensure_stream!(jts: js)
29
- ensure_consumer!(jts: js) if ensure_consumer
123
+ provision_stream!(jts: js)
124
+ provision_consumer!(jts: js) if provision_consumer
30
125
 
31
126
  Logging.info(
32
- "Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if ensure_consumer}",
127
+ "Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if provision_consumer}",
33
128
  tag: 'JetstreamBridge::Provisioner'
34
129
  )
35
130
 
36
131
  js
37
132
  end
38
133
 
39
- # Ensure stream only.
134
+ # Provision stream only.
40
135
  #
41
136
  # @param jts [Object, nil] Existing JetStream context (optional)
42
137
  # @return [Object] JetStream context used
43
- def ensure_stream!(jts: nil)
138
+ def provision_stream!(jts: nil)
44
139
  js = jts || Connection.connect!(verify_js: true)
45
- Topology.ensure!(js)
140
+ Topology.provision!(js)
46
141
  Logging.info(
47
- "Stream ensured: #{@config.stream_name}",
142
+ "Stream provisioned: #{@config.stream_name}",
48
143
  tag: 'JetstreamBridge::Provisioner'
49
144
  )
50
145
  js
51
146
  end
52
147
 
53
- # Ensure durable consumer only.
148
+ # Provision durable consumer only.
54
149
  #
55
150
  # @param jts [Object, nil] Existing JetStream context (optional)
56
151
  # @return [Object] JetStream context used
57
- def ensure_consumer!(jts: nil)
152
+ def provision_consumer!(jts: nil)
58
153
  js = jts || Connection.connect!(verify_js: true)
59
154
  SubscriptionManager.new(js, @config.durable_name, @config).ensure_consumer!(force: true)
60
155
  Logging.info(
61
- "Consumer ensured: #{@config.durable_name}",
156
+ "Consumer provisioned: #{@config.durable_name}",
62
157
  tag: 'JetstreamBridge::Provisioner'
63
158
  )
64
159
  js
@@ -35,29 +35,15 @@ module JetstreamBridge
35
35
  (record.respond_to?(:status) && record.status == 'sent')
36
36
  end
37
37
 
38
- def persist_pre(record, subject, envelope)
38
+ def record_publish_attempt(record, subject, envelope)
39
39
  ActiveRecord::Base.transaction do
40
- now = Time.now.utc
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 persist_success(record)
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 persist_failure(record, message)
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 persist_exception(record, error)
67
+ def record_publish_exception(record, error)
82
68
  return unless record
83
69
 
84
- persist_failure(record, "#{error.class}: #{error.message}")
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.persist_pre(record, subject, envelope)
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.persist_success(record)
259
+ repo.record_publish_success(record)
260
260
  else
261
- repo.persist_failure(record, result.error&.message || 'Publish failed')
261
+ repo.record_publish_failure(record, result.error&.message || 'Publish failed')
262
262
  end
263
263
  result
264
264
  rescue StandardError => e
265
- repo.persist_exception(record, e) if defined?(repo) && defined?(record)
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.connect_and_ensure_stream!
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 ensured'
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
- params = { name: name, subjects: subjects }
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
@@ -6,7 +6,7 @@ require_relative 'stream'
6
6
 
7
7
  module JetstreamBridge
8
8
  class Topology
9
- def self.ensure!(jts)
9
+ def self.provision!(jts)
10
10
  cfg = JetstreamBridge.config
11
11
  subjects = [cfg.source_subject, cfg.destination_subject]
12
12
  subjects << cfg.dlq_subject if cfg.use_dlq
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '5.1.0'
7
+ VERSION = '7.0.1'
8
8
  end
@@ -129,6 +129,7 @@ module JetstreamBridge
129
129
  def reset!
130
130
  @config = nil
131
131
  @connection_initialized = false
132
+ Consumer.reset_signal_handlers! if defined?(Consumer)
132
133
  end
133
134
 
134
135
  # Initialize the JetStream Bridge connection and topology
@@ -140,7 +141,7 @@ module JetstreamBridge
140
141
  return if @connection_initialized
141
142
 
142
143
  config.validate!
143
- connect_and_ensure_stream!
144
+ connect_and_provision!
144
145
  @connection_initialized = true
145
146
  Logging.info('JetStream Bridge started successfully', tag: 'JetstreamBridge')
146
147
  end
@@ -197,10 +198,10 @@ module JetstreamBridge
197
198
  config.use_dlq
198
199
  end
199
200
 
200
- # Establishes a connection and ensures stream topology.
201
+ # Establishes a connection and provisions stream topology.
201
202
  #
202
203
  # @return [Object] JetStream context
203
- def connect_and_ensure_stream!
204
+ def connect_and_provision!
204
205
  config.validate!
205
206
  provision = config.auto_provision
206
207
  Connection.connect!(verify_js: provision)
@@ -208,7 +209,7 @@ module JetstreamBridge
208
209
  raise ConnectionNotEstablishedError, 'JetStream connection not available' unless jts
209
210
 
210
211
  if provision
211
- Provisioner.new(config: config).ensure_stream!(jts: jts)
212
+ Provisioner.new(config: config).provision_stream!(jts: jts)
212
213
  else
213
214
  Logging.info(
214
215
  'auto_provision=false: skipping stream provisioning and JetStream account_info. ' \
@@ -222,16 +223,11 @@ module JetstreamBridge
222
223
 
223
224
  # Provision stream/consumer using management credentials (out of band from runtime).
224
225
  #
225
- # @param ensure_consumer [Boolean] Whether to create/align the consumer along with the stream.
226
+ # @param provision_consumer [Boolean] Whether to create/align the consumer along with the stream.
226
227
  # @return [Object] JetStream context
227
- def provision!(ensure_consumer: true)
228
+ def provision!(provision_consumer: true)
228
229
  config.validate!
229
- Provisioner.new(config: config).ensure!(ensure_consumer: ensure_consumer)
230
- end
231
-
232
- # Backwards-compatible alias for the previous method name
233
- def ensure_topology!
234
- connect_and_ensure_stream!
230
+ Provisioner.new(config: config).provision!(provision_consumer: provision_consumer)
235
231
  end
236
232
 
237
233
  # 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: 5.1.0
4
+ version: 7.0.1
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-28 00:00:00.000000000 Z
11
+ date: 2026-01-31 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,10 @@ 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
140
+ - lib/jetstream_bridge/config_helpers/lifecycle.rb
138
141
  - lib/jetstream_bridge/consumer/consumer.rb
142
+ - lib/jetstream_bridge/consumer/consumer_state.rb
139
143
  - lib/jetstream_bridge/consumer/dlq_publisher.rb
140
144
  - lib/jetstream_bridge/consumer/inbox/inbox_message.rb
141
145
  - lib/jetstream_bridge/consumer/inbox/inbox_processor.rb
@@ -150,6 +154,7 @@ files:
150
154
  - lib/jetstream_bridge/core/config_preset.rb
151
155
  - lib/jetstream_bridge/core/connection.rb
152
156
  - lib/jetstream_bridge/core/connection_factory.rb
157
+ - lib/jetstream_bridge/core/consumer_mode_resolver.rb
153
158
  - lib/jetstream_bridge/core/debug_helper.rb
154
159
  - lib/jetstream_bridge/core/duration.rb
155
160
  - lib/jetstream_bridge/core/logging.rb