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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +164 -0
- data/LICENSE +21 -0
- data/README.md +379 -0
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
- data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +58 -13
- data/lib/jetstream_bridge/consumer/consumer.rb +43 -0
- data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +3 -1
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -31
- data/lib/jetstream_bridge/consumer/message_processor.rb +65 -31
- data/lib/jetstream_bridge/core/config.rb +35 -0
- data/lib/jetstream_bridge/core/connection.rb +80 -3
- data/lib/jetstream_bridge/core/connection_factory.rb +102 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +107 -0
- data/lib/jetstream_bridge/core/duration.rb +8 -1
- data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
- data/lib/jetstream_bridge/errors.rb +39 -0
- data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
- data/lib/jetstream_bridge/models/subject.rb +94 -0
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
- data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
- data/lib/jetstream_bridge/railtie.rb +35 -1
- data/lib/jetstream_bridge/tasks/install.rake +99 -0
- data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
- data/lib/jetstream_bridge/topology/stream.rb +15 -5
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +65 -0
- 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
|