jetstream_bridge 2.10.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 (30) 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 +58 -13
  8. data/lib/jetstream_bridge/consumer/consumer.rb +43 -0
  9. data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
  10. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +3 -1
  11. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -31
  12. data/lib/jetstream_bridge/consumer/message_processor.rb +65 -31
  13. data/lib/jetstream_bridge/core/config.rb +35 -0
  14. data/lib/jetstream_bridge/core/connection.rb +80 -3
  15. data/lib/jetstream_bridge/core/connection_factory.rb +102 -0
  16. data/lib/jetstream_bridge/core/debug_helper.rb +107 -0
  17. data/lib/jetstream_bridge/core/duration.rb +8 -1
  18. data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
  19. data/lib/jetstream_bridge/errors.rb +39 -0
  20. data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
  21. data/lib/jetstream_bridge/models/subject.rb +94 -0
  22. data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
  23. data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
  24. data/lib/jetstream_bridge/railtie.rb +35 -1
  25. data/lib/jetstream_bridge/tasks/install.rake +99 -0
  26. data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
  27. data/lib/jetstream_bridge/topology/stream.rb +15 -5
  28. data/lib/jetstream_bridge/version.rb +1 -1
  29. data/lib/jetstream_bridge.rb +65 -0
  30. metadata +51 -7
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connection'
4
+ require_relative '../errors'
5
+
6
+ module JetstreamBridge
7
+ module Core
8
+ # Factory for creating and managing NATS connections
9
+ class ConnectionFactory
10
+ # Connection options builder
11
+ class ConnectionOptions
12
+ DEFAULT_OPTS = {
13
+ reconnect: true,
14
+ reconnect_time_wait: 2,
15
+ max_reconnect_attempts: 10,
16
+ connect_timeout: 5
17
+ }.freeze
18
+
19
+ attr_accessor :servers, :reconnect, :reconnect_time_wait,
20
+ :max_reconnect_attempts, :connect_timeout,
21
+ :name, :user, :pass, :token
22
+ attr_reader :additional_opts
23
+
24
+ def initialize(servers: nil, **opts)
25
+ @servers = normalize_servers(servers) if servers
26
+ @additional_opts = {}
27
+
28
+ DEFAULT_OPTS.merge(opts).each do |key, value|
29
+ if respond_to?(:"#{key}=")
30
+ send(:"#{key}=", value)
31
+ else
32
+ @additional_opts[key] = value
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.build(opts = {})
38
+ new(**opts)
39
+ end
40
+
41
+ def to_h
42
+ base = {
43
+ reconnect: @reconnect,
44
+ reconnect_time_wait: @reconnect_time_wait,
45
+ max_reconnect_attempts: @max_reconnect_attempts,
46
+ connect_timeout: @connect_timeout
47
+ }
48
+
49
+ base[:servers] = @servers if @servers
50
+ base[:name] = @name if @name
51
+ base[:user] = @user if @user
52
+ base[:pass] = @pass if @pass
53
+ base[:token] = @token if @token
54
+
55
+ base.merge(@additional_opts)
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_servers(servers)
61
+ Array(servers)
62
+ .flat_map { |s| s.to_s.split(',') }
63
+ .map(&:strip)
64
+ .reject(&:empty?)
65
+ end
66
+ end
67
+
68
+ class << self
69
+ # Create connection options from config
70
+ def build_options(config = JetstreamBridge.config)
71
+ servers = config.nats_urls
72
+ raise ConnectionNotEstablishedError, 'No NATS URLs configured' if servers.to_s.strip.empty?
73
+
74
+ ConnectionOptions.new(
75
+ servers: servers,
76
+ name: "#{config.app_name}-#{config.env}"
77
+ )
78
+ end
79
+
80
+ # Create a new NATS client
81
+ def create_client(options = nil)
82
+ opts = options || build_options
83
+ client = NATS::IO::Client.new
84
+ client.connect(opts.to_h)
85
+ client
86
+ end
87
+
88
+ # Create JetStream context with health monitoring
89
+ def create_jetstream(client)
90
+ jts = client.jetstream
91
+
92
+ # Ensure JetStream responds to #nc for compatibility
93
+ unless jts.respond_to?(:nc)
94
+ jts.define_singleton_method(:nc) { client }
95
+ end
96
+
97
+ jts
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ module JetstreamBridge
6
+ # Debug helper for troubleshooting JetStream Bridge operations
7
+ module DebugHelper
8
+ class << self
9
+ # Print comprehensive debug information about the current setup
10
+ def debug_info
11
+ info = {
12
+ config: config_debug,
13
+ connection: connection_debug,
14
+ stream: stream_debug,
15
+ health: JetstreamBridge.health_check
16
+ }
17
+
18
+ Logging.info("=== JetStream Bridge Debug Info ===", tag: 'JetstreamBridge::Debug')
19
+ info.each do |section, data|
20
+ Logging.info("#{section.to_s.upcase}:", tag: 'JetstreamBridge::Debug')
21
+ log_hash(data, indent: 2)
22
+ end
23
+ Logging.info("=== End Debug Info ===", tag: 'JetstreamBridge::Debug')
24
+
25
+ info
26
+ end
27
+
28
+ private
29
+
30
+ def config_debug
31
+ cfg = JetstreamBridge.config
32
+ {
33
+ env: cfg.env,
34
+ app_name: cfg.app_name,
35
+ destination_app: cfg.destination_app,
36
+ stream_name: cfg.stream_name,
37
+ source_subject: (cfg.source_subject rescue 'ERROR'),
38
+ destination_subject: (cfg.destination_subject rescue 'ERROR'),
39
+ dlq_subject: (cfg.dlq_subject rescue 'ERROR'),
40
+ durable_name: cfg.durable_name,
41
+ nats_urls: cfg.nats_urls,
42
+ max_deliver: cfg.max_deliver,
43
+ ack_wait: cfg.ack_wait,
44
+ backoff: cfg.backoff,
45
+ use_outbox: cfg.use_outbox,
46
+ use_inbox: cfg.use_inbox,
47
+ use_dlq: cfg.use_dlq,
48
+ outbox_model: cfg.outbox_model,
49
+ inbox_model: cfg.inbox_model
50
+ }
51
+ end
52
+
53
+ def connection_debug
54
+ conn = Connection.instance
55
+ {
56
+ connected: conn.connected?,
57
+ connected_at: conn.connected_at&.iso8601,
58
+ nc_present: !conn.instance_variable_get(:@nc).nil?,
59
+ jts_present: !conn.instance_variable_get(:@jts).nil?
60
+ }
61
+ rescue StandardError => e
62
+ { error: "#{e.class}: #{e.message}" }
63
+ end
64
+
65
+ def stream_debug
66
+ return { error: 'Not connected' } unless Connection.instance.connected?
67
+
68
+ jts = Connection.jetstream
69
+ cfg = JetstreamBridge.config
70
+ info = jts.stream_info(cfg.stream_name)
71
+
72
+ {
73
+ name: cfg.stream_name,
74
+ exists: true,
75
+ subjects: info.config.subjects,
76
+ retention: info.config.retention,
77
+ storage: info.config.storage,
78
+ max_consumers: info.config.max_consumers,
79
+ messages: info.state.messages,
80
+ bytes: info.state.bytes,
81
+ first_seq: info.state.first_seq,
82
+ last_seq: info.state.last_seq
83
+ }
84
+ rescue StandardError => e
85
+ {
86
+ name: JetstreamBridge.config.stream_name,
87
+ exists: false,
88
+ error: "#{e.class}: #{e.message}"
89
+ }
90
+ end
91
+
92
+ def log_hash(hash, indent: 0)
93
+ prefix = ' ' * indent
94
+ hash.each do |key, value|
95
+ if value.is_a?(Hash)
96
+ Logging.info("#{prefix}#{key}:", tag: 'JetstreamBridge::Debug')
97
+ log_hash(value, indent: indent + 2)
98
+ elsif value.is_a?(Array)
99
+ Logging.info("#{prefix}#{key}: #{value.inspect}", tag: 'JetstreamBridge::Debug')
100
+ else
101
+ Logging.info("#{prefix}#{key}: #{value}", tag: 'JetstreamBridge::Debug')
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -71,7 +71,14 @@ module JetstreamBridge
71
71
  def int_to_ms(num, default_unit:)
72
72
  case default_unit
73
73
  when :auto
74
- # Preserve existing heuristic for compatibility
74
+ # Preserve existing heuristic for compatibility but log deprecation warning
75
+ if defined?(Logging) && num > 0 && num < 1_000
76
+ Logging.debug(
77
+ "Duration :auto heuristic treating #{num} as seconds. " \
78
+ "Consider specifying default_unit: :s or :ms for clarity.",
79
+ tag: 'JetstreamBridge::Duration'
80
+ )
81
+ end
75
82
  num >= 1_000 ? num : num * 1_000
76
83
  else
77
84
  coerce_numeric_to_ms(num.to_f, default_unit)
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ begin
6
+ require 'nats/io/client'
7
+ rescue LoadError
8
+ # NATS not available, PublisherRetryStrategy won't work but that's ok for tests
9
+ end
10
+
11
+ module JetstreamBridge
12
+ # Base retry strategy interface
13
+ class RetryStrategy
14
+ class RetryExhausted < StandardError; end
15
+
16
+ def initialize(max_attempts:, backoffs: [], transient_errors: [])
17
+ @max_attempts = max_attempts
18
+ @backoffs = backoffs
19
+ @transient_errors = transient_errors
20
+ end
21
+
22
+ # Execute block with retry logic
23
+ # @yield Block to execute with retry
24
+ # @return Result of the block
25
+ # @raise RetryExhausted if all attempts fail
26
+ def execute(context: nil)
27
+ attempts = 0
28
+ last_error = nil
29
+
30
+ loop do
31
+ attempts += 1
32
+ begin
33
+ return yield
34
+ rescue *retryable_errors => e
35
+ last_error = e
36
+ raise e if attempts >= @max_attempts
37
+
38
+ delay = calculate_delay(attempts, e)
39
+ log_retry(attempts, e, delay, context)
40
+ sleep delay
41
+ end
42
+ end
43
+ rescue => e
44
+ raise unless retryable?(e)
45
+ raise RetryExhausted, "Failed after #{attempts} attempts: #{e.message}"
46
+ end
47
+
48
+ protected
49
+
50
+ def calculate_delay(attempt, _error)
51
+ @backoffs[attempt - 1] || @backoffs.last || 1.0
52
+ end
53
+
54
+ def retryable_errors
55
+ @transient_errors.empty? ? [StandardError] : @transient_errors
56
+ end
57
+
58
+ def retryable?(error)
59
+ retryable_errors.any? { |klass| error.is_a?(klass) }
60
+ end
61
+
62
+ def log_retry(attempt, error, delay, context)
63
+ ctx_info = context ? " [#{context}]" : ''
64
+ Logging.warn(
65
+ "Retry #{attempt}/#{@max_attempts}#{ctx_info}: #{error.class} - #{error.message}, " \
66
+ "waiting #{delay}s",
67
+ tag: 'JetstreamBridge::RetryStrategy'
68
+ )
69
+ end
70
+ end
71
+
72
+ # Exponential backoff retry strategy
73
+ class ExponentialBackoffStrategy < RetryStrategy
74
+ def initialize(max_attempts: 3, base_delay: 0.1, max_delay: 60, multiplier: 2, transient_errors: [], jitter: true)
75
+ @base_delay = base_delay
76
+ @max_delay = max_delay
77
+ @multiplier = multiplier
78
+ @jitter = jitter
79
+ backoffs = compute_backoffs(max_attempts)
80
+ super(max_attempts: max_attempts, backoffs: backoffs, transient_errors: transient_errors)
81
+ end
82
+
83
+ protected
84
+
85
+ def calculate_delay(attempt, _error)
86
+ @backoffs[attempt - 1] || @backoffs.last || @base_delay
87
+ end
88
+
89
+ private
90
+
91
+ def compute_backoffs(max_attempts)
92
+ (0...max_attempts).map do |i|
93
+ delay = @base_delay * (@multiplier**i)
94
+ if @jitter
95
+ # Add up to 10% jitter, but ensure we don't exceed max_delay
96
+ jitter_amount = delay * 0.1 * rand
97
+ delay = [delay + jitter_amount, @max_delay].min
98
+ else
99
+ delay = [delay, @max_delay].min
100
+ end
101
+ delay
102
+ end
103
+ end
104
+ end
105
+
106
+ # Linear backoff retry strategy
107
+ class LinearBackoffStrategy < RetryStrategy
108
+ def initialize(max_attempts: 3, delays: [0.25, 1.0, 2.0], transient_errors: [])
109
+ super(max_attempts: max_attempts, backoffs: delays, transient_errors: transient_errors)
110
+ end
111
+ end
112
+
113
+ # Publisher-specific retry strategy
114
+ class PublisherRetryStrategy < LinearBackoffStrategy
115
+ TRANSIENT_ERRORS = begin
116
+ errs = []
117
+ if defined?(NATS::IO)
118
+ errs << NATS::IO::Timeout if defined?(NATS::IO::Timeout)
119
+ errs << NATS::IO::Error if defined?(NATS::IO::Error)
120
+ errs << NATS::IO::NoServersError if defined?(NATS::IO::NoServersError)
121
+ errs << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
122
+ errs << Errno::ECONNREFUSED
123
+ end
124
+ errs
125
+ end.freeze
126
+
127
+ def initialize(max_attempts: 2)
128
+ super(
129
+ max_attempts: max_attempts,
130
+ delays: [0.25, 1.0],
131
+ transient_errors: TRANSIENT_ERRORS
132
+ )
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ # Base error for all JetStream Bridge errors
5
+ class Error < StandardError; end
6
+
7
+ # Configuration errors
8
+ class ConfigurationError < Error; end
9
+ class InvalidSubjectError < ConfigurationError; end
10
+ class MissingConfigurationError < ConfigurationError; end
11
+
12
+ # Connection errors
13
+ class ConnectionError < Error; end
14
+ class ConnectionNotEstablishedError < ConnectionError; end
15
+ class HealthCheckFailedError < ConnectionError; end
16
+
17
+ # Publisher errors
18
+ class PublishError < Error; end
19
+ class PublishFailedError < PublishError; end
20
+ class OutboxError < PublishError; end
21
+
22
+ # Consumer errors
23
+ class ConsumerError < Error; end
24
+ class HandlerError < ConsumerError; end
25
+ class InboxError < ConsumerError; end
26
+
27
+ # Topology errors
28
+ class TopologyError < Error; end
29
+ class StreamNotFoundError < TopologyError; end
30
+ class SubjectOverlapError < TopologyError; end
31
+ class StreamCreationFailedError < TopologyError; end
32
+
33
+ # DLQ errors
34
+ class DlqError < Error; end
35
+ class DlqPublishFailedError < DlqError; end
36
+
37
+ # Retry errors (already defined in retry_strategy.rb)
38
+ # class RetryExhausted < Error; end
39
+ end
@@ -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