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.
- 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 +61 -13
- data/lib/generators/jetstream_bridge/install/install_generator.rb +4 -2
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +1 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +50 -9
- data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +8 -2
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -61
- data/lib/jetstream_bridge/consumer/message_processor.rb +105 -33
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +13 -2
- data/lib/jetstream_bridge/core/config.rb +37 -1
- 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/logging.rb +20 -7
- data/lib/jetstream_bridge/core/model_utils.rb +4 -3
- data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
- data/lib/jetstream_bridge/errors.rb +39 -0
- data/lib/jetstream_bridge/inbox_event.rb +4 -4
- data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
- data/lib/jetstream_bridge/models/subject.rb +94 -0
- data/lib/jetstream_bridge/outbox_event.rb +3 -1
- 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 +16 -8
- data/lib/jetstream_bridge/topology/subject_matcher.rb +17 -7
- data/lib/jetstream_bridge/topology/topology.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +63 -6
- metadata +51 -10
- data/lib/jetstream_bridge/consumer/backoff_strategy.rb +0 -24
- data/lib/jetstream_bridge/consumer/consumer_config.rb +0 -26
- data/lib/jetstream_bridge/consumer/message_context.rb +0 -22
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
|
|
3
5
|
module JetstreamBridge
|
|
4
6
|
class Config
|
|
5
7
|
attr_accessor :destination_app, :nats_urls, :env, :app_name,
|
|
6
8
|
:max_deliver, :ack_wait, :backoff,
|
|
7
9
|
:use_outbox, :use_inbox, :inbox_model, :outbox_model,
|
|
8
|
-
:use_dlq
|
|
10
|
+
:use_dlq, :logger
|
|
9
11
|
|
|
10
12
|
def initialize
|
|
11
13
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
@@ -22,6 +24,7 @@ module JetstreamBridge
|
|
|
22
24
|
@use_dlq = true
|
|
23
25
|
@outbox_model = 'JetstreamBridge::OutboxEvent'
|
|
24
26
|
@inbox_model = 'JetstreamBridge::InboxEvent'
|
|
27
|
+
@logger = nil
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
# Single stream name per env
|
|
@@ -33,20 +36,53 @@ module JetstreamBridge
|
|
|
33
36
|
# Producer publishes to: {env}.{app}.sync.{dest}
|
|
34
37
|
# Consumer subscribes to: {env}.{dest}.sync.{app}
|
|
35
38
|
def source_subject
|
|
39
|
+
validate_subject_component!(env, 'env')
|
|
40
|
+
validate_subject_component!(app_name, 'app_name')
|
|
41
|
+
validate_subject_component!(destination_app, 'destination_app')
|
|
36
42
|
"#{env}.#{app_name}.sync.#{destination_app}"
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
def destination_subject
|
|
46
|
+
validate_subject_component!(env, 'env')
|
|
47
|
+
validate_subject_component!(app_name, 'app_name')
|
|
48
|
+
validate_subject_component!(destination_app, 'destination_app')
|
|
40
49
|
"#{env}.#{destination_app}.sync.#{app_name}"
|
|
41
50
|
end
|
|
42
51
|
|
|
43
52
|
# DLQ
|
|
44
53
|
def dlq_subject
|
|
54
|
+
validate_subject_component!(env, 'env')
|
|
45
55
|
"#{env}.sync.dlq"
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
def durable_name
|
|
49
59
|
"#{env}-#{app_name}-workers"
|
|
50
60
|
end
|
|
61
|
+
|
|
62
|
+
# Validate configuration settings
|
|
63
|
+
def validate!
|
|
64
|
+
errors = []
|
|
65
|
+
errors << 'destination_app is required' if destination_app.to_s.strip.empty?
|
|
66
|
+
errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
|
|
67
|
+
errors << 'env is required' if env.to_s.strip.empty?
|
|
68
|
+
errors << 'app_name is required' if app_name.to_s.strip.empty?
|
|
69
|
+
errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
|
|
70
|
+
errors << 'backoff must be an array' unless backoff.is_a?(Array)
|
|
71
|
+
errors << 'backoff must not be empty' if backoff.is_a?(Array) && backoff.empty?
|
|
72
|
+
|
|
73
|
+
raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def validate_subject_component!(value, name)
|
|
81
|
+
str = value.to_s
|
|
82
|
+
if str.match?(/[.*>]/)
|
|
83
|
+
raise InvalidSubjectError, "#{name} cannot contain NATS wildcards (., *, >): #{value.inspect}"
|
|
84
|
+
end
|
|
85
|
+
raise MissingConfigurationError, "#{name} cannot be empty" if str.strip.empty?
|
|
86
|
+
end
|
|
51
87
|
end
|
|
52
88
|
end
|
|
@@ -9,7 +9,21 @@ require_relative 'config'
|
|
|
9
9
|
require_relative '../topology/topology'
|
|
10
10
|
|
|
11
11
|
module JetstreamBridge
|
|
12
|
-
# Singleton connection to NATS.
|
|
12
|
+
# Singleton connection to NATS with thread-safe initialization.
|
|
13
|
+
#
|
|
14
|
+
# This class manages a single NATS connection for the entire application,
|
|
15
|
+
# ensuring thread-safe access in multi-threaded environments like Rails
|
|
16
|
+
# with Puma or Sidekiq.
|
|
17
|
+
#
|
|
18
|
+
# Thread Safety:
|
|
19
|
+
# - Connection initialization is synchronized with a mutex
|
|
20
|
+
# - The singleton pattern ensures only one connection instance exists
|
|
21
|
+
# - Safe to call from multiple threads/workers simultaneously
|
|
22
|
+
#
|
|
23
|
+
# Example:
|
|
24
|
+
# # Safe from any thread
|
|
25
|
+
# jts = JetstreamBridge::Connection.connect!
|
|
26
|
+
# jts.publish(...)
|
|
13
27
|
class Connection
|
|
14
28
|
include Singleton
|
|
15
29
|
|
|
@@ -23,6 +37,10 @@ module JetstreamBridge
|
|
|
23
37
|
class << self
|
|
24
38
|
# Thread-safe delegator to the singleton instance.
|
|
25
39
|
# Returns a live JetStream context.
|
|
40
|
+
#
|
|
41
|
+
# Safe to call from multiple threads - uses mutex for synchronization.
|
|
42
|
+
#
|
|
43
|
+
# @return [NATS::JetStream::JS] JetStream context
|
|
26
44
|
def connect!
|
|
27
45
|
@__mutex ||= Mutex.new
|
|
28
46
|
@__mutex.synchronize { instance.connect! }
|
|
@@ -56,13 +74,34 @@ module JetstreamBridge
|
|
|
56
74
|
# Ensure topology (streams, subjects, overlap guard, etc.)
|
|
57
75
|
Topology.ensure!(@jts)
|
|
58
76
|
|
|
77
|
+
@connected_at = Time.now.utc
|
|
59
78
|
@jts
|
|
60
79
|
end
|
|
61
80
|
|
|
81
|
+
# Public API for checking connection status
|
|
82
|
+
# @return [Boolean] true if NATS client is connected and JetStream is healthy
|
|
83
|
+
def connected?
|
|
84
|
+
@nc&.connected? && @jts && jetstream_healthy?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Public API for getting connection timestamp
|
|
88
|
+
# @return [Time, nil] timestamp when connection was established
|
|
89
|
+
def connected_at
|
|
90
|
+
@connected_at
|
|
91
|
+
end
|
|
92
|
+
|
|
62
93
|
private
|
|
63
94
|
|
|
64
|
-
def
|
|
65
|
-
|
|
95
|
+
def jetstream_healthy?
|
|
96
|
+
# Verify JetStream responds to simple API call
|
|
97
|
+
@jts.account_info
|
|
98
|
+
true
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
Logging.warn(
|
|
101
|
+
"JetStream health check failed: #{e.class} #{e.message}",
|
|
102
|
+
tag: 'JetstreamBridge::Connection'
|
|
103
|
+
)
|
|
104
|
+
false
|
|
66
105
|
end
|
|
67
106
|
|
|
68
107
|
def nats_servers
|
|
@@ -75,6 +114,30 @@ module JetstreamBridge
|
|
|
75
114
|
|
|
76
115
|
def establish_connection(servers)
|
|
77
116
|
@nc = NATS::IO::Client.new
|
|
117
|
+
|
|
118
|
+
# Setup reconnect handler to refresh JetStream context
|
|
119
|
+
@nc.on_reconnect do
|
|
120
|
+
Logging.info(
|
|
121
|
+
'NATS reconnected, refreshing JetStream context',
|
|
122
|
+
tag: 'JetstreamBridge::Connection'
|
|
123
|
+
)
|
|
124
|
+
refresh_jetstream_context
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@nc.on_disconnect do |reason|
|
|
128
|
+
Logging.warn(
|
|
129
|
+
"NATS disconnected: #{reason}",
|
|
130
|
+
tag: 'JetstreamBridge::Connection'
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@nc.on_error do |err|
|
|
135
|
+
Logging.error(
|
|
136
|
+
"NATS error: #{err}",
|
|
137
|
+
tag: 'JetstreamBridge::Connection'
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
78
141
|
@nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
|
|
79
142
|
|
|
80
143
|
# Create JetStream context
|
|
@@ -87,6 +150,20 @@ module JetstreamBridge
|
|
|
87
150
|
@jts.define_singleton_method(:nc) { nc_ref }
|
|
88
151
|
end
|
|
89
152
|
|
|
153
|
+
def refresh_jetstream_context
|
|
154
|
+
@jts = @nc.jetstream
|
|
155
|
+
nc_ref = @nc
|
|
156
|
+
@jts.define_singleton_method(:nc) { nc_ref } unless @jts.respond_to?(:nc)
|
|
157
|
+
|
|
158
|
+
# Re-ensure topology after reconnect
|
|
159
|
+
Topology.ensure!(@jts)
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
Logging.error(
|
|
162
|
+
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
163
|
+
tag: 'JetstreamBridge::Connection'
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
90
167
|
# Expose for class-level helpers (not part of public API)
|
|
91
168
|
attr_reader :nc
|
|
92
169
|
|
|
@@ -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)
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'uri'
|
|
4
|
+
require 'logger'
|
|
4
5
|
|
|
5
6
|
module JetstreamBridge
|
|
6
|
-
# Logging helpers that route to
|
|
7
|
-
# falling back to STDOUT.
|
|
7
|
+
# Logging helpers that route to the configured logger when available,
|
|
8
|
+
# falling back to Rails.logger or STDOUT.
|
|
8
9
|
module Logging
|
|
9
10
|
module_function
|
|
10
11
|
|
|
12
|
+
def logger
|
|
13
|
+
JetstreamBridge.config.logger ||
|
|
14
|
+
(defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
|
|
15
|
+
default_logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def default_logger
|
|
19
|
+
@default_logger ||= Logger.new($stdout)
|
|
20
|
+
end
|
|
21
|
+
|
|
11
22
|
def log(level, msg, tag: nil)
|
|
12
23
|
message = tag ? "[#{tag}] #{msg}" : msg
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
logger.public_send(level, message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def debug(msg, tag: nil)
|
|
28
|
+
log(:debug, msg, tag: tag)
|
|
18
29
|
end
|
|
19
30
|
|
|
20
31
|
def info(msg, tag: nil)
|
|
@@ -29,6 +40,7 @@ module JetstreamBridge
|
|
|
29
40
|
log(:error, msg, tag: tag)
|
|
30
41
|
end
|
|
31
42
|
|
|
43
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
32
44
|
def sanitize_url(url)
|
|
33
45
|
uri = URI.parse(url)
|
|
34
46
|
return url unless uri.user || uri.password
|
|
@@ -55,5 +67,6 @@ module JetstreamBridge
|
|
|
55
67
|
"#{scheme}://#{masked}@"
|
|
56
68
|
end
|
|
57
69
|
end
|
|
70
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
58
71
|
end
|
|
59
72
|
end
|
|
@@ -14,10 +14,13 @@ module JetstreamBridge
|
|
|
14
14
|
defined?(ActiveRecord::Base) && klass <= ActiveRecord::Base
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
17
18
|
def has_columns?(klass, *cols)
|
|
18
19
|
return false unless ar_class?(klass)
|
|
20
|
+
|
|
19
21
|
cols.flatten.all? { |c| klass.column_names.include?(c.to_s) }
|
|
20
22
|
end
|
|
23
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
21
24
|
|
|
22
25
|
def assign_known_attrs(record, attrs)
|
|
23
26
|
attrs.each do |k, v|
|
|
@@ -30,9 +33,7 @@ module JetstreamBridge
|
|
|
30
33
|
def find_or_init_by_best(klass, *keysets)
|
|
31
34
|
keysets.each do |keys|
|
|
32
35
|
next if keys.nil? || keys.empty?
|
|
33
|
-
if has_columns?(klass, keys.keys)
|
|
34
|
-
return klass.find_or_initialize_by(keys)
|
|
35
|
-
end
|
|
36
|
+
return klass.find_or_initialize_by(keys) if has_columns?(klass, keys.keys)
|
|
36
37
|
end
|
|
37
38
|
klass.new
|
|
38
39
|
end
|
|
@@ -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
|