jetstream_bridge 2.9.0 → 3.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +164 -0
  3. data/LICENSE +21 -0
  4. data/README.md +379 -0
  5. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
  6. data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
  7. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +61 -13
  8. data/lib/generators/jetstream_bridge/install/install_generator.rb +4 -2
  9. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +1 -0
  10. data/lib/jetstream_bridge/consumer/consumer.rb +50 -9
  11. data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
  12. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +8 -2
  13. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -61
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +105 -33
  15. data/lib/jetstream_bridge/consumer/subscription_manager.rb +13 -2
  16. data/lib/jetstream_bridge/core/config.rb +37 -1
  17. data/lib/jetstream_bridge/core/connection.rb +80 -3
  18. data/lib/jetstream_bridge/core/connection_factory.rb +102 -0
  19. data/lib/jetstream_bridge/core/debug_helper.rb +107 -0
  20. data/lib/jetstream_bridge/core/duration.rb +8 -1
  21. data/lib/jetstream_bridge/core/logging.rb +20 -7
  22. data/lib/jetstream_bridge/core/model_utils.rb +4 -3
  23. data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
  24. data/lib/jetstream_bridge/errors.rb +39 -0
  25. data/lib/jetstream_bridge/inbox_event.rb +4 -4
  26. data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
  27. data/lib/jetstream_bridge/models/subject.rb +94 -0
  28. data/lib/jetstream_bridge/outbox_event.rb +3 -1
  29. data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
  30. data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
  31. data/lib/jetstream_bridge/railtie.rb +35 -1
  32. data/lib/jetstream_bridge/tasks/install.rake +99 -0
  33. data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
  34. data/lib/jetstream_bridge/topology/stream.rb +16 -8
  35. data/lib/jetstream_bridge/topology/subject_matcher.rb +17 -7
  36. data/lib/jetstream_bridge/topology/topology.rb +1 -1
  37. data/lib/jetstream_bridge/version.rb +1 -1
  38. data/lib/jetstream_bridge.rb +63 -6
  39. metadata +51 -10
  40. data/lib/jetstream_bridge/consumer/backoff_strategy.rb +0 -24
  41. data/lib/jetstream_bridge/consumer/consumer_config.rb +0 -26
  42. data/lib/jetstream_bridge/consumer/message_context.rb +0 -22
@@ -15,6 +15,7 @@ module JetstreamBridge
15
15
 
16
16
  class << self
17
17
  # Safe column presence check that never boots a connection during class load.
18
+ # rubocop:disable Naming/PredicatePrefix
18
19
  def has_column?(name)
19
20
  return false unless ar_connected?
20
21
 
@@ -22,6 +23,7 @@ module JetstreamBridge
22
23
  rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
23
24
  false
24
25
  end
26
+ # rubocop:enable Naming/PredicatePrefix
25
27
 
26
28
  def ar_connected?
27
29
  ActiveRecord::Base.connected? && connection_pool.active_connection?
@@ -66,9 +68,7 @@ module JetstreamBridge
66
68
  # ---- Defaults that do not require schema at load time ----
67
69
  before_validation do
68
70
  self.status ||= 'received' if self.class.has_column?(:status) && status.blank?
69
- if self.class.has_column?(:received_at) && received_at.blank?
70
- self.received_at ||= Time.now.utc
71
- end
71
+ self.received_at ||= Time.now.utc if self.class.has_column?(:received_at) && received_at.blank?
72
72
  end
73
73
 
74
74
  # ---- Helpers ----
@@ -103,7 +103,7 @@ module JetstreamBridge
103
103
  raise_missing_ar!('Inbox', method_name)
104
104
  end
105
105
 
106
- def respond_to_missing?(_m, _p = false)
106
+ def respond_to_missing?(_method_name, _include_private = false)
107
107
  false
108
108
  end
109
109
 
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'time'
5
+
6
+ module JetstreamBridge
7
+ module Models
8
+ # Value object representing an event envelope
9
+ class EventEnvelope
10
+ SCHEMA_VERSION = 1
11
+
12
+ attr_reader :event_id, :schema_version, :event_type, :producer,
13
+ :resource_type, :resource_id, :occurred_at, :trace_id, :payload
14
+
15
+ def initialize(
16
+ resource_type:,
17
+ event_type:,
18
+ payload:,
19
+ event_id: nil,
20
+ occurred_at: nil,
21
+ trace_id: nil,
22
+ producer: nil,
23
+ resource_id: nil
24
+ )
25
+ @event_id = event_id || SecureRandom.uuid
26
+ @schema_version = SCHEMA_VERSION
27
+ @event_type = event_type.to_s
28
+ @producer = producer || JetstreamBridge.config.app_name
29
+ @resource_type = resource_type.to_s
30
+ @resource_id = resource_id || extract_resource_id(payload)
31
+ @occurred_at = parse_occurred_at(occurred_at)
32
+ @trace_id = trace_id || SecureRandom.hex(8)
33
+ @payload = deep_freeze(payload)
34
+
35
+ validate!
36
+ freeze
37
+ end
38
+
39
+ # Convert to hash for serialization
40
+ def to_h
41
+ hash = {
42
+ event_id: @event_id,
43
+ schema_version: @schema_version,
44
+ event_type: @event_type,
45
+ producer: @producer,
46
+ resource_type: @resource_type,
47
+ occurred_at: format_time(@occurred_at),
48
+ payload: @payload
49
+ }
50
+
51
+ # Only include optional fields if they have values
52
+ hash[:resource_id] = @resource_id if @resource_id && !@resource_id.to_s.empty?
53
+ hash[:trace_id] = @trace_id if @trace_id && !@trace_id.to_s.empty?
54
+
55
+ hash
56
+ end
57
+
58
+ # Create from hash
59
+ def self.from_h(hash)
60
+ new(
61
+ event_id: hash['event_id'] || hash[:event_id],
62
+ event_type: hash['event_type'] || hash[:event_type],
63
+ producer: hash['producer'] || hash[:producer],
64
+ resource_type: hash['resource_type'] || hash[:resource_type],
65
+ resource_id: hash['resource_id'] || hash[:resource_id],
66
+ occurred_at: parse_time(hash['occurred_at'] || hash[:occurred_at]),
67
+ trace_id: hash['trace_id'] || hash[:trace_id],
68
+ payload: hash['payload'] || hash[:payload] || {}
69
+ )
70
+ end
71
+
72
+ def ==(other)
73
+ other.is_a?(EventEnvelope) && event_id == other.event_id
74
+ end
75
+
76
+ alias eql? ==
77
+
78
+ def hash
79
+ event_id.hash
80
+ end
81
+
82
+ private
83
+
84
+ def extract_resource_id(payload)
85
+ return '' unless payload.respond_to?(:[])
86
+
87
+ (payload['id'] || payload[:id]).to_s
88
+ end
89
+
90
+ def validate!
91
+ raise ArgumentError, 'event_type cannot be blank' if @event_type.empty?
92
+ raise ArgumentError, 'resource_type cannot be blank' if @resource_type.empty?
93
+ raise ArgumentError, 'payload cannot be nil' if @payload.nil?
94
+ end
95
+
96
+ def parse_occurred_at(value)
97
+ return Time.now.utc if value.nil?
98
+ return value if value.is_a?(Time)
99
+
100
+ Time.parse(value.to_s)
101
+ rescue ArgumentError
102
+ Time.now.utc
103
+ end
104
+
105
+ def format_time(time)
106
+ time.is_a?(Time) ? time.iso8601 : time.to_s
107
+ end
108
+
109
+ def deep_freeze(obj)
110
+ case obj
111
+ when Hash
112
+ obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
113
+ obj.freeze
114
+ when Array
115
+ obj.each { |item| deep_freeze(item) }
116
+ obj.freeze
117
+ else
118
+ obj.freeze if obj.respond_to?(:freeze)
119
+ end
120
+ obj
121
+ end
122
+
123
+ def self.parse_time(value)
124
+ return value if value.is_a?(Time)
125
+ return Time.now.utc if value.nil?
126
+
127
+ Time.parse(value.to_s)
128
+ rescue ArgumentError
129
+ Time.now.utc
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ module Models
5
+ # Value object representing a NATS subject
6
+ class Subject
7
+ WILDCARD_SINGLE = '*'
8
+ WILDCARD_MULTI = '>'
9
+ SEPARATOR = '.'
10
+ INVALID_CHARS = /[#{Regexp.escape(WILDCARD_SINGLE + WILDCARD_MULTI + SEPARATOR)}]/.freeze
11
+
12
+ attr_reader :value, :tokens
13
+
14
+ def initialize(value)
15
+ @value = value.to_s
16
+ @tokens = @value.split(SEPARATOR)
17
+ validate!
18
+ @value.freeze
19
+ @tokens.freeze
20
+ freeze
21
+ end
22
+
23
+ # Factory methods
24
+ def self.source(env:, app_name:, dest:)
25
+ new("#{env}.#{app_name}.sync.#{dest}")
26
+ end
27
+
28
+ def self.destination(env:, source:, app_name:)
29
+ new("#{env}.#{source}.sync.#{app_name}")
30
+ end
31
+
32
+ def self.dlq(env:)
33
+ new("#{env}.sync.dlq")
34
+ end
35
+
36
+ # Check if this subject matches a pattern
37
+ def matches?(pattern)
38
+ SubjectMatcher.match?(pattern.to_s, @value)
39
+ end
40
+
41
+ # Check if this subject overlaps with another
42
+ def overlaps?(other)
43
+ SubjectMatcher.overlap?(@value, other.to_s)
44
+ end
45
+
46
+ # Check if covered by any pattern in a list
47
+ def covered_by?(patterns)
48
+ SubjectMatcher.covered?(Array(patterns).map(&:to_s), @value)
49
+ end
50
+
51
+ def to_s
52
+ @value
53
+ end
54
+
55
+ def ==(other)
56
+ other.is_a?(Subject) ? @value == other.value : @value == other.to_s
57
+ end
58
+
59
+ alias eql? ==
60
+
61
+ def hash
62
+ @value.hash
63
+ end
64
+
65
+ # Validate a component (env, app_name, etc.) for use in subjects
66
+ def self.validate_component!(value, name)
67
+ str = value.to_s
68
+ if str.match?(INVALID_CHARS)
69
+ raise ArgumentError,
70
+ "#{name} cannot contain NATS wildcards (#{SEPARATOR}, #{WILDCARD_SINGLE}, #{WILDCARD_MULTI}): #{value.inspect}"
71
+ end
72
+ raise ArgumentError, "#{name} cannot be empty" if str.strip.empty?
73
+
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def validate!
80
+ raise ArgumentError, 'Subject cannot be empty' if @value.empty?
81
+ raise ArgumentError, 'Subject cannot contain only separators' if @tokens.all?(&:empty?)
82
+ raise ArgumentError, 'Subject has invalid format (contains spaces or special characters)' if @value.match?(/\s/)
83
+ end
84
+
85
+ # Lazy-load SubjectMatcher to avoid circular dependency
86
+ def self.subject_matcher
87
+ require_relative '../topology/subject_matcher' unless defined?(JetstreamBridge::SubjectMatcher)
88
+ JetstreamBridge::SubjectMatcher
89
+ end
90
+
91
+ SubjectMatcher = subject_matcher
92
+ end
93
+ end
94
+ end
@@ -15,6 +15,7 @@ module JetstreamBridge
15
15
 
16
16
  class << self
17
17
  # Safe column presence check that never boots a connection during class load.
18
+ # rubocop:disable Naming/PredicatePrefix
18
19
  def has_column?(name)
19
20
  return false unless ar_connected?
20
21
 
@@ -22,6 +23,7 @@ module JetstreamBridge
22
23
  rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
23
24
  false
24
25
  end
26
+ # rubocop:enable Naming/PredicatePrefix
25
27
 
26
28
  def ar_connected?
27
29
  # Avoid creating a connection; rescue if pool isn't set yet.
@@ -111,7 +113,7 @@ module JetstreamBridge
111
113
  raise_missing_ar!('Outbox', method_name)
112
114
  end
113
115
 
114
- def respond_to_missing?(_m, _p = false)
116
+ def respond_to_missing?(_method_name, _include_private = false)
115
117
  false
116
118
  end
117
119
 
@@ -11,52 +11,71 @@ module JetstreamBridge
11
11
  end
12
12
 
13
13
  def find_or_build(event_id)
14
- ModelUtils.find_or_init_by_best(
14
+ record = ModelUtils.find_or_init_by_best(
15
15
  @klass,
16
16
  { event_id: event_id },
17
17
  { dedup_key: event_id } # fallback if app uses a different unique column
18
18
  )
19
+
20
+ # Lock the row to prevent concurrent processing
21
+ if record.persisted? && !record.new_record? && record.respond_to?(:lock!)
22
+ begin
23
+ record.lock!
24
+ rescue ActiveRecord::RecordNotFound
25
+ # Record was deleted between find and lock, create new
26
+ record = @klass.new
27
+ end
28
+ end
29
+
30
+ record
19
31
  end
20
32
 
21
33
  def already_sent?(record)
22
- record.respond_to?(:sent_at) && record.sent_at
34
+ (record.respond_to?(:sent_at) && record.sent_at) ||
35
+ (record.respond_to?(:status) && record.status == 'sent')
23
36
  end
24
37
 
25
38
  def persist_pre(record, subject, envelope)
26
- now = Time.now.utc
27
- event_id = envelope['event_id'].to_s
39
+ ActiveRecord::Base.transaction do
40
+ now = Time.now.utc
41
+ event_id = envelope['event_id'].to_s
28
42
 
29
- attrs = {
30
- event_id: event_id,
31
- subject: subject,
32
- payload: ModelUtils.json_dump(envelope),
33
- headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
34
- status: 'publishing',
35
- last_error: nil
36
- }
37
- attrs[:attempts] = 1 + (record.attempts || 0) if record.respond_to?(:attempts)
38
- attrs[:enqueued_at] = (record.enqueued_at || now) if record.respond_to?(:enqueued_at)
39
- attrs[:updated_at] = now if record.respond_to?(:updated_at)
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)
40
54
 
41
- ModelUtils.assign_known_attrs(record, attrs)
42
- record.save!
55
+ ModelUtils.assign_known_attrs(record, attrs)
56
+ record.save!
57
+ end
43
58
  end
44
59
 
45
60
  def persist_success(record)
46
- now = Time.now.utc
47
- attrs = { status: 'sent' }
48
- attrs[:sent_at] = now if record.respond_to?(:sent_at)
49
- attrs[:updated_at] = now if record.respond_to?(:updated_at)
50
- ModelUtils.assign_known_attrs(record, attrs)
51
- record.save!
61
+ ActiveRecord::Base.transaction do
62
+ now = Time.now.utc
63
+ attrs = { status: 'sent' }
64
+ attrs[:sent_at] = now if record.respond_to?(:sent_at)
65
+ attrs[:updated_at] = now if record.respond_to?(:updated_at)
66
+ ModelUtils.assign_known_attrs(record, attrs)
67
+ record.save!
68
+ end
52
69
  end
53
70
 
54
71
  def persist_failure(record, message)
55
- now = Time.now.utc
56
- attrs = { status: 'failed', last_error: message }
57
- attrs[:updated_at] = now if record.respond_to?(:updated_at)
58
- ModelUtils.assign_known_attrs(record, attrs)
59
- record.save!
72
+ ActiveRecord::Base.transaction do
73
+ now = Time.now.utc
74
+ attrs = { status: 'failed', last_error: message }
75
+ attrs[:updated_at] = now if record.respond_to?(:updated_at)
76
+ ModelUtils.assign_known_attrs(record, attrs)
77
+ record.save!
78
+ end
60
79
  end
61
80
 
62
81
  def persist_exception(record, error)
@@ -6,22 +6,15 @@ require_relative '../core/connection'
6
6
  require_relative '../core/logging'
7
7
  require_relative '../core/config'
8
8
  require_relative '../core/model_utils'
9
+ require_relative '../core/retry_strategy'
9
10
  require_relative 'outbox_repository'
10
11
 
11
12
  module JetstreamBridge
12
13
  # Publishes to "{env}.{app}.sync.{dest}".
13
14
  class Publisher
14
- DEFAULT_RETRIES = 2
15
- RETRY_BACKOFFS = [0.25, 1.0].freeze
16
-
17
- TRANSIENT_ERRORS = begin
18
- errs = [NATS::IO::Timeout, NATS::IO::Error]
19
- errs << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
20
- errs.freeze
21
- end
22
-
23
- def initialize
15
+ def initialize(retry_strategy: nil)
24
16
  @jts = Connection.connect!
17
+ @retry_strategy = retry_strategy || PublisherRetryStrategy.new
25
18
  end
26
19
 
27
20
  # @return [Boolean]
@@ -33,7 +26,7 @@ module JetstreamBridge
33
26
  if JetstreamBridge.config.use_outbox
34
27
  publish_via_outbox(subject, envelope)
35
28
  else
36
- with_retries { do_publish?(subject, envelope) }
29
+ with_retries { publish_to_nats(subject, envelope) }
37
30
  end
38
31
  rescue StandardError => e
39
32
  log_error(false, e)
@@ -47,7 +40,7 @@ module JetstreamBridge
47
40
  raise ArgumentError, 'destination_app must be configured'
48
41
  end
49
42
 
50
- def do_publish?(subject, envelope)
43
+ def publish_to_nats(subject, envelope)
51
44
  headers = { 'nats-msg-id' => envelope['event_id'] }
52
45
 
53
46
  ack = @jts.publish(subject, Oj.dump(envelope, mode: :compat), header: headers)
@@ -76,7 +69,7 @@ module JetstreamBridge
76
69
  "Outbox model #{klass} is not an ActiveRecord model; publishing directly.",
77
70
  tag: 'JetstreamBridge::Publisher'
78
71
  )
79
- return with_retries { do_publish?(subject, envelope) }
72
+ return with_retries { publish_to_nats(subject, envelope) }
80
73
  end
81
74
 
82
75
  repo = OutboxRepository.new(klass)
@@ -93,7 +86,7 @@ module JetstreamBridge
93
86
 
94
87
  repo.persist_pre(record, subject, envelope)
95
88
 
96
- ok = with_retries { do_publish?(subject, envelope) }
89
+ ok = with_retries { publish_to_nats(subject, envelope) }
97
90
  ok ? repo.persist_success(record) : repo.persist_failure(record, 'Publish returned false')
98
91
  ok
99
92
  rescue StandardError => e
@@ -102,27 +95,11 @@ module JetstreamBridge
102
95
  end
103
96
  # ---- /Outbox path ----
104
97
 
105
- # Retry only on transient NATS IO errors
106
- def with_retries(retries = DEFAULT_RETRIES)
107
- attempts = 0
108
- begin
109
- yield
110
- rescue *TRANSIENT_ERRORS => e
111
- attempts += 1
112
- return log_error(false, e) if attempts > retries
113
-
114
- backoff(attempts, e)
115
- retry
116
- end
117
- end
118
-
119
- def backoff(attempts, error)
120
- delay = RETRY_BACKOFFS[attempts - 1] || RETRY_BACKOFFS.last
121
- Logging.warn(
122
- "Publish retry #{attempts} after #{error.class}: #{error.message}",
123
- tag: 'JetstreamBridge::Publisher'
124
- )
125
- sleep delay
98
+ # Retry using strategy pattern
99
+ def with_retries
100
+ @retry_strategy.execute(context: 'Publisher') { yield }
101
+ rescue RetryStrategy::RetryExhausted => e
102
+ log_error(false, e)
126
103
  end
127
104
 
128
105
  def log_error(val, exc)
@@ -1,17 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'core/model_codec_setup'
4
+ require_relative 'core/logging'
4
5
 
5
6
  module JetstreamBridge
6
7
  class Railtie < ::Rails::Railtie
7
- initializer 'jetstream_bridge.defer_model_tweaks' do
8
+ # Set up logger to use Rails.logger by default
9
+ initializer 'jetstream_bridge.logger', before: :initialize_logger do
10
+ JetstreamBridge.configure do |config|
11
+ config.logger ||= Rails.logger if defined?(Rails.logger)
12
+ end
13
+ end
14
+
15
+ # Load ActiveRecord model tweaks after ActiveRecord is loaded
16
+ initializer 'jetstream_bridge.active_record', after: 'active_record.initialize_database' do
8
17
  ActiveSupport.on_load(:active_record) do
9
18
  ActiveSupport::Reloader.to_prepare { JetstreamBridge::ModelCodecSetup.apply! }
10
19
  end
11
20
  end
12
21
 
22
+ # Validate configuration in development/test
23
+ initializer 'jetstream_bridge.validate_config', after: :load_config_initializers do |app|
24
+ if Rails.env.development? || Rails.env.test?
25
+ app.config.after_initialize do
26
+ begin
27
+ JetstreamBridge.config.validate! if JetstreamBridge.config.destination_app
28
+ rescue JetstreamBridge::ConfigurationError => e
29
+ Rails.logger.warn "[JetStream Bridge] Configuration warning: #{e.message}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # Add console helper methods
36
+ console do
37
+ Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
38
+ Rails.logger.info "[JetStream Bridge] Use JetstreamBridge.health_check to check status"
39
+ end
40
+
41
+ # Load rake tasks
13
42
  rake_tasks do
14
43
  load File.expand_path('tasks/install.rake', __dir__)
15
44
  end
45
+
46
+ # Add generators
47
+ generators do
48
+ require 'generators/jetstream_bridge/health_check/health_check_generator'
49
+ end
16
50
  end
17
51
  end
@@ -7,4 +7,103 @@ namespace :jetstream_bridge do
7
7
  Rails::Generators.invoke('jetstream_bridge:install', [], behavior: :invoke, destination_root: Rails.root.to_s)
8
8
  puts '[jetstream_bridge] Done.'
9
9
  end
10
+
11
+ desc 'Check health and connection status'
12
+ task health: :environment do
13
+ require 'json'
14
+
15
+ puts '=' * 70
16
+ puts 'JetStream Bridge Health Check'
17
+ puts '=' * 70
18
+
19
+ health = JetstreamBridge.health_check
20
+
21
+ puts "\nStatus: #{health[:healthy] ? '✓ HEALTHY' : '✗ UNHEALTHY'}"
22
+ puts "NATS Connected: #{health[:nats_connected] ? 'Yes' : 'No'}"
23
+ puts "Connected At: #{health[:connected_at] || 'N/A'}"
24
+ puts "Version: #{health[:version]}"
25
+
26
+ if health[:stream]
27
+ puts "\nStream:"
28
+ puts " Name: #{health[:stream][:name]}"
29
+ puts " Exists: #{health[:stream][:exists] ? 'Yes' : 'No'}"
30
+ if health[:stream][:subjects]
31
+ puts " Subjects: #{health[:stream][:subjects].join(', ')}"
32
+ puts " Messages: #{health[:stream][:messages]}"
33
+ end
34
+ puts " Error: #{health[:stream][:error]}" if health[:stream][:error]
35
+ end
36
+
37
+ if health[:config]
38
+ puts "\nConfiguration:"
39
+ puts " Environment: #{health[:config][:env]}"
40
+ puts " App Name: #{health[:config][:app_name]}"
41
+ puts " Destination: #{health[:config][:destination_app] || 'NOT SET'}"
42
+ puts " Outbox: #{health[:config][:use_outbox] ? 'Enabled' : 'Disabled'}"
43
+ puts " Inbox: #{health[:config][:use_inbox] ? 'Enabled' : 'Disabled'}"
44
+ puts " DLQ: #{health[:config][:use_dlq] ? 'Enabled' : 'Disabled'}"
45
+ end
46
+
47
+ if health[:error]
48
+ puts "\n#{' ERROR '.center(70, '=')}"
49
+ puts health[:error]
50
+ end
51
+
52
+ puts '=' * 70
53
+
54
+ exit(health[:healthy] ? 0 : 1)
55
+ end
56
+
57
+ desc 'Validate configuration'
58
+ task validate: :environment do
59
+ puts '[jetstream_bridge] Validating configuration...'
60
+
61
+ begin
62
+ JetstreamBridge.config.validate!
63
+ puts '✓ Configuration is valid'
64
+ puts "\nCurrent settings:"
65
+ puts " Environment: #{JetstreamBridge.config.env}"
66
+ puts " App Name: #{JetstreamBridge.config.app_name}"
67
+ puts " Destination: #{JetstreamBridge.config.destination_app}"
68
+ puts " Stream: #{JetstreamBridge.config.stream_name}"
69
+ puts " Source Subject: #{JetstreamBridge.config.source_subject}"
70
+ puts " Destination Subject: #{JetstreamBridge.config.destination_subject}"
71
+ exit 0
72
+ rescue JetstreamBridge::ConfigurationError => e
73
+ puts "✗ Configuration error: #{e.message}"
74
+ exit 1
75
+ end
76
+ end
77
+
78
+ desc 'Show debug information'
79
+ task debug: :environment do
80
+ JetstreamBridge::DebugHelper.debug_info
81
+ end
82
+
83
+ desc 'Test connection to NATS'
84
+ task test_connection: :environment do
85
+ puts '[jetstream_bridge] Testing NATS connection...'
86
+
87
+ begin
88
+ jts = JetstreamBridge.ensure_topology!
89
+ puts '✓ Successfully connected to NATS'
90
+ puts "✓ JetStream is available"
91
+ puts "✓ Stream topology ensured"
92
+
93
+ # Check if we can get account info
94
+ info = jts.account_info
95
+ puts "\nAccount Info:"
96
+ puts " Memory: #{info.memory}"
97
+ puts " Storage: #{info.storage}"
98
+ puts " Streams: #{info.streams}"
99
+ puts " Consumers: #{info.consumers}"
100
+
101
+ exit 0
102
+ rescue StandardError => e
103
+ puts "✗ Connection failed: #{e.message}"
104
+ puts "\nBacktrace:" if ENV['VERBOSE']
105
+ puts e.backtrace.first(10).map { |line| " #{line}" }.join("\n") if ENV['VERBOSE']
106
+ exit 1
107
+ end
108
+ end
10
109
  end
@@ -55,11 +55,25 @@ module JetstreamBridge
55
55
  def list_stream_names(jts)
56
56
  names = []
57
57
  offset = 0
58
+ max_iterations = 100 # Safety limit to prevent infinite loops
59
+ iterations = 0
60
+
58
61
  loop do
62
+ iterations += 1
63
+ if iterations > max_iterations
64
+ Logging.warn(
65
+ "Stream listing exceeded max iterations (#{max_iterations}), returning #{names.size} streams",
66
+ tag: 'JetstreamBridge::OverlapGuard'
67
+ )
68
+ break
69
+ end
70
+
59
71
  resp = js_api_request(jts, '$JS.API.STREAM.NAMES', { offset: offset })
60
72
  batch = Array(resp['streams']).filter_map { |h| h['name'] }
61
73
  names.concat(batch)
62
- break if names.size >= resp['total'].to_i || batch.empty?
74
+ total = resp['total'].to_i
75
+
76
+ break if names.size >= total || batch.empty?
63
77
 
64
78
  offset = names.size
65
79
  end