jetstream_bridge 1.4.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 +7 -0
- data/.github/workflows/release.yml +150 -0
- data/.gitignore +56 -0
- data/.idea/.gitignore +8 -0
- data/.idea/dictionaries/project.xml +14 -0
- data/.idea/jetstream_bridge.iml +97 -0
- data/.idea/misc.xml +4 -0
- data/.idea/modules.xml +8 -0
- data/.idea/vcs.xml +6 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +274 -0
- data/LICENSE +21 -0
- data/README.md +217 -0
- data/jetstream_bridge.gemspec +61 -0
- data/lib/jetstream_bridge/config.rb +48 -0
- data/lib/jetstream_bridge/connection.rb +75 -0
- data/lib/jetstream_bridge/consumer.rb +106 -0
- data/lib/jetstream_bridge/consumer_config.rb +32 -0
- data/lib/jetstream_bridge/dlq.rb +24 -0
- data/lib/jetstream_bridge/duration.rb +46 -0
- data/lib/jetstream_bridge/inbox_event.rb +46 -0
- data/lib/jetstream_bridge/logging.rb +59 -0
- data/lib/jetstream_bridge/message_processor.rb +65 -0
- data/lib/jetstream_bridge/outbox_event.rb +60 -0
- data/lib/jetstream_bridge/overlap_guard.rb +65 -0
- data/lib/jetstream_bridge/publisher.rb +90 -0
- data/lib/jetstream_bridge/stream.rb +66 -0
- data/lib/jetstream_bridge/subject_matcher.rb +65 -0
- data/lib/jetstream_bridge/topology.rb +22 -0
- data/lib/jetstream_bridge/version.rb +8 -0
- data/lib/jetstream_bridge.rb +51 -0
- metadata +237 -0
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module JetstreamBridge
|
6
|
+
# Logging helpers that route to Rails.logger when available,
|
7
|
+
# falling back to STDOUT.
|
8
|
+
module Logging
|
9
|
+
module_function
|
10
|
+
|
11
|
+
def log(level, msg, tag: nil)
|
12
|
+
message = tag ? "[#{tag}] #{msg}" : msg
|
13
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
14
|
+
Rails.logger.public_send(level, message)
|
15
|
+
else
|
16
|
+
puts "[#{level.to_s.upcase}] #{message}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def info(msg, tag: nil)
|
21
|
+
log(:info, msg, tag: tag)
|
22
|
+
end
|
23
|
+
|
24
|
+
def warn(msg, tag: nil)
|
25
|
+
log(:warn, msg, tag: tag)
|
26
|
+
end
|
27
|
+
|
28
|
+
def error(msg, tag: nil)
|
29
|
+
log(:error, msg, tag: tag)
|
30
|
+
end
|
31
|
+
|
32
|
+
def sanitize_url(url)
|
33
|
+
uri = URI.parse(url)
|
34
|
+
return url unless uri.user || uri.password
|
35
|
+
|
36
|
+
userinfo =
|
37
|
+
if uri.password # user:pass → keep user, mask pass
|
38
|
+
"#{uri.user}:***"
|
39
|
+
else # token-only userinfo → mask entirely
|
40
|
+
'***'
|
41
|
+
end
|
42
|
+
|
43
|
+
host = uri.host || ''
|
44
|
+
port = uri.port ? ":#{uri.port}" : ''
|
45
|
+
path = uri.path.to_s # omit query on purpose to avoid leaking tokens
|
46
|
+
frag = uri.fragment ? "##{uri.fragment}" : ''
|
47
|
+
|
48
|
+
"#{uri.scheme}://#{userinfo}@#{host}#{port}#{path}#{frag}"
|
49
|
+
rescue URI::InvalidURIError
|
50
|
+
# Fallback: redact any userinfo before the '@'
|
51
|
+
url.gsub(%r{(nats|tls)://([^@/]+)@}i) do
|
52
|
+
scheme = Regexp.last_match(1)
|
53
|
+
creds = Regexp.last_match(2)
|
54
|
+
masked = creds&.include?(':') ? "#{creds&.split(':', 2)&.first}:***" : '***'
|
55
|
+
"#{scheme}://#{masked}@"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'securerandom'
|
5
|
+
require_relative 'logging'
|
6
|
+
|
7
|
+
module JetstreamBridge
|
8
|
+
# Handles parse → handler → ack / nak → DLQ
|
9
|
+
class MessageProcessor
|
10
|
+
def initialize(jts, handler)
|
11
|
+
@jts = jts
|
12
|
+
@handler = handler
|
13
|
+
end
|
14
|
+
|
15
|
+
def handle_message(msg)
|
16
|
+
deliveries = msg.metadata&.num_delivered.to_i
|
17
|
+
event_id = msg.header&.[]('Nats-Msg-Id') || SecureRandom.uuid
|
18
|
+
event = parse_message(msg, event_id)
|
19
|
+
return unless event
|
20
|
+
|
21
|
+
process_event(msg, event, deliveries, event_id)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def parse_message(msg, event_id)
|
27
|
+
JSON.parse(msg.data)
|
28
|
+
rescue JSON::ParserError => e
|
29
|
+
publish_to_dlq!(msg)
|
30
|
+
msg.ack
|
31
|
+
Logging.warn("Malformed JSON to DLQ event_id=#{event_id}: #{e.message}",
|
32
|
+
tag: 'JetstreamBridge::Consumer')
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def process_event(msg, event, deliveries, event_id)
|
37
|
+
@handler.call(event, msg.subject, deliveries)
|
38
|
+
msg.ack
|
39
|
+
rescue StandardError => e
|
40
|
+
ack_or_nak(msg, deliveries, event_id, e)
|
41
|
+
end
|
42
|
+
|
43
|
+
def ack_or_nak(msg, deliveries, event_id, error)
|
44
|
+
if deliveries >= JetstreamBridge.config.max_deliver.to_i
|
45
|
+
publish_to_dlq!(msg)
|
46
|
+
msg.ack
|
47
|
+
Logging.warn("Sent to DLQ after max_deliver event_id=#{event_id} err=#{error.message}",
|
48
|
+
tag: 'JetstreamBridge::Consumer')
|
49
|
+
else
|
50
|
+
msg.nak
|
51
|
+
Logging.warn("NAK event_id=#{event_id} deliveries=#{deliveries} err=#{error.message}",
|
52
|
+
tag: 'JetstreamBridge::Consumer')
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def publish_to_dlq!(msg)
|
57
|
+
return unless JetstreamBridge.config.use_dlq
|
58
|
+
|
59
|
+
@jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: msg.header)
|
60
|
+
rescue StandardError => e
|
61
|
+
Logging.error("DLQ publish failed: #{e.class} #{e.message}",
|
62
|
+
tag: 'JetstreamBridge::Consumer')
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# If ActiveRecord is not available, a shim class is defined that raises
|
4
|
+
# a helpful error when used.
|
5
|
+
begin
|
6
|
+
require 'active_record'
|
7
|
+
rescue LoadError
|
8
|
+
# Ignore; we handle the lack of AR below.
|
9
|
+
end
|
10
|
+
|
11
|
+
module JetstreamBridge
|
12
|
+
# OutboxEvent is the default ActiveRecord model used by the gem when
|
13
|
+
# `use_outbox` is enabled.
|
14
|
+
# It stores pending events that will be flushed
|
15
|
+
# to NATS JetStream by your worker/cron.
|
16
|
+
if defined?(ActiveRecord::Base)
|
17
|
+
class OutboxEvent < ActiveRecord::Base
|
18
|
+
self.table_name = 'jetstream_outbox_events'
|
19
|
+
|
20
|
+
# Prefer native JSON/JSONB column.
|
21
|
+
# If the column is text, `serialize` with JSON keeps backward compatibility.
|
22
|
+
if respond_to?(:attribute_types) &&
|
23
|
+
attribute_types.key?('payload') &&
|
24
|
+
attribute_types['payload'].type.in?(%i[json jsonb])
|
25
|
+
# No-op: AR already gives JSON casting.
|
26
|
+
else
|
27
|
+
serialize :payload, coder: JSON
|
28
|
+
end
|
29
|
+
|
30
|
+
# Minimal validations that are generally useful; keep them short.
|
31
|
+
validates :resource_type, presence: true
|
32
|
+
validates :resource_id, presence: true
|
33
|
+
validates :event_type, presence: true
|
34
|
+
validates :payload, presence: true
|
35
|
+
end
|
36
|
+
else
|
37
|
+
# Shim that fails loudly if the app misconfigures the gem without AR.
|
38
|
+
class OutboxEvent
|
39
|
+
class << self
|
40
|
+
def method_missing(method_name, *_args, &_block)
|
41
|
+
raise_missing_ar!('Outbox', method_name)
|
42
|
+
end
|
43
|
+
|
44
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
45
|
+
false
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def raise_missing_ar!(which, method_name)
|
51
|
+
raise(
|
52
|
+
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
53
|
+
'Enable `use_outbox` only in apps with ActiveRecord, or add ' \
|
54
|
+
'`gem "activerecord"` to your Gemfile.'
|
55
|
+
)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module JetstreamBridge
|
6
|
+
# Checks for overlapping subjects.
|
7
|
+
class OverlapGuard
|
8
|
+
class << self
|
9
|
+
def check!(jts, target_name, new_subjects)
|
10
|
+
conflicts = overlaps(jts, target_name, new_subjects)
|
11
|
+
return if conflicts.empty?
|
12
|
+
raise conflict_message(target_name, conflicts)
|
13
|
+
end
|
14
|
+
|
15
|
+
def overlaps(jts, target_name, new_subjects)
|
16
|
+
desired = Array(new_subjects).map!(&:to_s).uniq
|
17
|
+
streams = list_streams_with_subjects(jts)
|
18
|
+
others = streams.reject { |s| s[:name] == target_name }
|
19
|
+
|
20
|
+
others.map do |s|
|
21
|
+
pairs = desired.flat_map do |n|
|
22
|
+
Array(s[:subjects]).map(&:to_s).select { |e| SubjectMatcher.overlap?(n, e) }
|
23
|
+
.map { |e| [n, e] }
|
24
|
+
end
|
25
|
+
{ name: s[:name], pairs: pairs } unless pairs.empty?
|
26
|
+
end.compact
|
27
|
+
end
|
28
|
+
|
29
|
+
def list_streams_with_subjects(jts)
|
30
|
+
list_stream_names(jts).map do |name|
|
31
|
+
info = jts.stream_info(name)
|
32
|
+
{ name: name, subjects: Array(info.config.subjects || []) }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def list_stream_names(jts)
|
37
|
+
names = []
|
38
|
+
offset = 0
|
39
|
+
loop do
|
40
|
+
resp = js_api_request(jts, '$JS.API.STREAM.NAMES', { offset: offset })
|
41
|
+
batch = Array(resp['streams']).map { |h| h['name'] }.compact
|
42
|
+
names.concat(batch)
|
43
|
+
break if names.size >= resp['total'].to_i || batch.empty?
|
44
|
+
offset = names.size
|
45
|
+
end
|
46
|
+
names
|
47
|
+
end
|
48
|
+
|
49
|
+
def js_api_request(jts, subject, payload = {})
|
50
|
+
# JetStream client should expose the underlying NATS client as `nc`
|
51
|
+
msg = jts.nc.request(subject, JSON.dump(payload))
|
52
|
+
JSON.parse(msg.data)
|
53
|
+
end
|
54
|
+
|
55
|
+
def conflict_message(target, conflicts)
|
56
|
+
msg = +"Overlapping subjects for stream #{target}:\n"
|
57
|
+
conflicts.each do |c|
|
58
|
+
msg << "- Conflicts with '#{c[:name]}' on:\n"
|
59
|
+
c[:pairs].each { |(a, b)| msg << " • #{a} × #{b}\n" }
|
60
|
+
end
|
61
|
+
msg
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'securerandom'
|
5
|
+
require_relative 'connection'
|
6
|
+
require_relative 'logging'
|
7
|
+
require_relative 'config'
|
8
|
+
|
9
|
+
module JetstreamBridge
|
10
|
+
# Publishes to "{env}.data.sync.{app}.{dest}".
|
11
|
+
class Publisher
|
12
|
+
DEFAULT_RETRIES = 2
|
13
|
+
RETRY_BACKOFFS = [0.25, 1.0].freeze
|
14
|
+
|
15
|
+
TRANSIENT_ERRORS = begin
|
16
|
+
errs = [NATS::IO::Timeout, NATS::IO::Error]
|
17
|
+
errs << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
|
18
|
+
errs.freeze
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize
|
22
|
+
@jts = Connection.connect!
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Boolean]
|
26
|
+
def publish(resource_type:, event_type:, payload:, **options)
|
27
|
+
ensure_destination!
|
28
|
+
envelope = build_envelope(resource_type, event_type, payload, options)
|
29
|
+
subject = JetstreamBridge.config.source_subject
|
30
|
+
with_retries { do_publish(subject, envelope) }
|
31
|
+
rescue StandardError => e
|
32
|
+
log_error(false, e)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def ensure_destination!
|
38
|
+
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
39
|
+
raise ArgumentError, 'destination_app must be configured'
|
40
|
+
end
|
41
|
+
|
42
|
+
def do_publish(subject, envelope)
|
43
|
+
headers = { 'Nats-Msg-Id' => envelope['event_id'] }
|
44
|
+
@jts.publish(subject, JSON.generate(envelope), header: headers)
|
45
|
+
Logging.info("Published #{subject} event_id=#{envelope['event_id']}",
|
46
|
+
tag: 'JetstreamBridge::Publisher')
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
# Retry only on transient NATS errors
|
51
|
+
def with_retries(retries = DEFAULT_RETRIES)
|
52
|
+
attempts = 0
|
53
|
+
begin
|
54
|
+
return yield
|
55
|
+
rescue *TRANSIENT_ERRORS => e
|
56
|
+
attempts += 1
|
57
|
+
return log_error(false, e) if attempts > retries
|
58
|
+
backoff(attempts, e)
|
59
|
+
retry
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def backoff(attempts, error)
|
64
|
+
delay = RETRY_BACKOFFS[attempts - 1] || RETRY_BACKOFFS.last
|
65
|
+
Logging.warn("Publish retry #{attempts} after #{error.class}: #{error.message}",
|
66
|
+
tag: 'JetstreamBridge::Publisher')
|
67
|
+
sleep delay
|
68
|
+
end
|
69
|
+
|
70
|
+
def log_error(val, exc)
|
71
|
+
Logging.error("Publish failed: #{exc.class} #{exc.message}",
|
72
|
+
tag: 'JetstreamBridge::Publisher')
|
73
|
+
val
|
74
|
+
end
|
75
|
+
|
76
|
+
def build_envelope(resource_type, event_type, payload, options = {})
|
77
|
+
{
|
78
|
+
'event_id' => options[:event_id] || SecureRandom.uuid,
|
79
|
+
'schema_version' => 1,
|
80
|
+
'event_type' => event_type,
|
81
|
+
'producer' => JetstreamBridge.config.app_name,
|
82
|
+
'resource_id' => (payload['id'] || payload[:id]).to_s,
|
83
|
+
'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
|
84
|
+
'trace_id' => options[:trace_id] || SecureRandom.hex(8),
|
85
|
+
'resource_type' => resource_type,
|
86
|
+
'payload' => payload
|
87
|
+
}
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'overlap_guard'
|
4
|
+
require_relative 'logging'
|
5
|
+
require_relative 'subject_matcher'
|
6
|
+
|
7
|
+
module JetstreamBridge
|
8
|
+
# Ensures a stream exists and adds only subjects that are not already covered.
|
9
|
+
class Stream
|
10
|
+
class << self
|
11
|
+
def ensure!(jts, name, subjects)
|
12
|
+
desired = normalize_subjects(subjects)
|
13
|
+
raise ArgumentError, 'subjects must not be empty' if desired.empty?
|
14
|
+
|
15
|
+
begin
|
16
|
+
info = jts.stream_info(name)
|
17
|
+
existing = normalize_subjects(info.config.subjects || [])
|
18
|
+
|
19
|
+
# Skip anything already COVERED by existing patterns (not just exact match)
|
20
|
+
missing = desired.reject { |d| SubjectMatcher.covered?(existing, d) }
|
21
|
+
if missing.empty?
|
22
|
+
Logging.info("Stream #{name} exists; subjects already covered.", tag: 'JetstreamBridge::Stream')
|
23
|
+
return
|
24
|
+
end
|
25
|
+
|
26
|
+
# Validate full target set against other streams
|
27
|
+
target = (existing + missing).uniq
|
28
|
+
OverlapGuard.check!(jts, name, target)
|
29
|
+
|
30
|
+
# Try to update; handle late overlaps/races
|
31
|
+
jts.update_stream(name: name, subjects: target)
|
32
|
+
Logging.info("Updated stream #{name}; added subjects=#{missing.inspect}", tag: 'JetstreamBridge::Stream')
|
33
|
+
rescue NATS::JetStream::Error => e
|
34
|
+
if stream_not_found?(e)
|
35
|
+
# Race: created elsewhere or genuinely missing — create fresh
|
36
|
+
OverlapGuard.check!(jts, name, desired)
|
37
|
+
jts.add_stream(name: name, subjects: desired, retention: 'interest', storage: 'file')
|
38
|
+
Logging.info("Created stream #{name} subjects=#{desired.inspect}", tag: 'JetstreamBridge::Stream')
|
39
|
+
elsif overlap_error?(e)
|
40
|
+
# Late overlap due to concurrent change — recompute and raise with details
|
41
|
+
conflicts = OverlapGuard.overlaps(jts, name, desired)
|
42
|
+
raise OverlapGuard.conflict_message(name, conflicts)
|
43
|
+
else
|
44
|
+
raise
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def normalize_subjects(list)
|
52
|
+
Array(list).flatten.compact.map!(&:to_s).reject(&:empty?).uniq
|
53
|
+
end
|
54
|
+
|
55
|
+
def stream_not_found?(error)
|
56
|
+
msg = error.message.to_s
|
57
|
+
msg =~ /stream\s+not\s+found/i || msg =~ /\b404\b/
|
58
|
+
end
|
59
|
+
|
60
|
+
def overlap_error?(error)
|
61
|
+
msg = error.message.to_s
|
62
|
+
msg =~ /subjects?\s+overlap/i || msg =~ /\berr_code=10065\b/ || msg =~ /\bstatus_code=400\b/
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JetstreamBridge
|
4
|
+
# Subject matching helpers.
|
5
|
+
module SubjectMatcher
|
6
|
+
module_function
|
7
|
+
|
8
|
+
def covered?(patterns, subject)
|
9
|
+
Array(patterns).any? { |pat| match?(pat.to_s, subject.to_s) }
|
10
|
+
end
|
11
|
+
|
12
|
+
# Proper NATS semantics:
|
13
|
+
# - '*' matches exactly one token
|
14
|
+
# - '>' matches the rest (zero or more tokens) but ONLY after the fixed prefix matches
|
15
|
+
def match?(pattern, subject)
|
16
|
+
p = pattern.split('.')
|
17
|
+
s = subject.split('.')
|
18
|
+
|
19
|
+
i = 0
|
20
|
+
while i < p.length && i < s.length
|
21
|
+
token = p[i]
|
22
|
+
case token
|
23
|
+
when '>'
|
24
|
+
return true # tail wildcard absorbs the rest
|
25
|
+
when '*'
|
26
|
+
# matches this token; continue
|
27
|
+
else
|
28
|
+
return false unless token == s[i]
|
29
|
+
end
|
30
|
+
i += 1
|
31
|
+
end
|
32
|
+
|
33
|
+
# If the pattern still has tokens, it only matches if the remainder is a '>' (or contains one)
|
34
|
+
return true if i == p.length && i == s.length
|
35
|
+
|
36
|
+
p[i] == '>' || p[i..-1]&.include?('>')
|
37
|
+
end
|
38
|
+
|
39
|
+
# Do two wildcard patterns admit at least one same subject?
|
40
|
+
def overlap?(a, b)
|
41
|
+
overlap_parts?(a.split('.'), b.split('.'))
|
42
|
+
end
|
43
|
+
|
44
|
+
def overlap_parts?(a_parts, b_parts)
|
45
|
+
ai = 0
|
46
|
+
bi = 0
|
47
|
+
while ai < a_parts.length && bi < b_parts.length
|
48
|
+
at = a_parts[ai]
|
49
|
+
bt = b_parts[bi]
|
50
|
+
return true if at == '>' || bt == '>' # either can absorb the rest
|
51
|
+
return false unless at == bt || at == '*' || bt == '*' # fixed tokens differ
|
52
|
+
ai += 1
|
53
|
+
bi += 1
|
54
|
+
end
|
55
|
+
|
56
|
+
# If any side still has a '>' remaining, it can absorb the other's remainder
|
57
|
+
a_tail = a_parts[ai..-1] || []
|
58
|
+
b_tail = b_parts[bi..-1] || []
|
59
|
+
return true if a_tail.include?('>') || b_tail.include?('>')
|
60
|
+
|
61
|
+
# Otherwise they overlap only if both consumed exactly
|
62
|
+
ai == a_parts.length && bi == b_parts.length
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'stream'
|
4
|
+
require_relative 'config'
|
5
|
+
require_relative 'logging'
|
6
|
+
|
7
|
+
module JetstreamBridge
|
8
|
+
class Topology
|
9
|
+
def self.ensure!(jts)
|
10
|
+
cfg = JetstreamBridge.config
|
11
|
+
subjects = [cfg.source_subject, cfg.destination_subject]
|
12
|
+
subjects << cfg.dlq_subject if cfg.use_dlq
|
13
|
+
Stream.ensure!(jts, cfg.stream_name, subjects)
|
14
|
+
|
15
|
+
Logging.info(
|
16
|
+
"Subjects ready: producer=#{cfg.source_subject}, consumer=#{cfg.destination_subject}. " \
|
17
|
+
"Counterpart publishes on #{cfg.destination_subject} and subscribes on #{cfg.source_subject}.",
|
18
|
+
tag: 'JetstreamBridge::Topology'
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'jetstream_bridge/version'
|
4
|
+
require_relative 'jetstream_bridge/config'
|
5
|
+
require_relative 'jetstream_bridge/duration'
|
6
|
+
require_relative 'jetstream_bridge/logging'
|
7
|
+
require_relative 'jetstream_bridge/connection'
|
8
|
+
require_relative 'jetstream_bridge/publisher'
|
9
|
+
require_relative 'jetstream_bridge/consumer'
|
10
|
+
|
11
|
+
# JetstreamBridge
|
12
|
+
#
|
13
|
+
# Top-level module that exposes configuration and autoloads optional AR models.
|
14
|
+
# Use `JetstreamBridge.configure` to set defaults for your environment.
|
15
|
+
module JetstreamBridge
|
16
|
+
autoload :OutboxEvent, 'jetstream_bridge/outbox_event'
|
17
|
+
autoload :InboxEvent, 'jetstream_bridge/inbox_event'
|
18
|
+
|
19
|
+
class << self
|
20
|
+
# Access the global configuration.
|
21
|
+
# @return [JetstreamBridge::Config]
|
22
|
+
def config
|
23
|
+
@config ||= Config.new
|
24
|
+
end
|
25
|
+
|
26
|
+
# Configure via hash and/or block.
|
27
|
+
# @param overrides [Hash] optional config key/value pairs
|
28
|
+
# @yieldparam [JetstreamBridge::Config] config
|
29
|
+
# @return [JetstreamBridge::Config]
|
30
|
+
def configure(overrides = {})
|
31
|
+
cfg = config
|
32
|
+
overrides.each { |k, v| assign!(cfg, k, v) }
|
33
|
+
yield(cfg) if block_given?
|
34
|
+
cfg
|
35
|
+
end
|
36
|
+
|
37
|
+
# Reset memoized config (useful in tests).
|
38
|
+
def reset!
|
39
|
+
@config = nil
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def assign!(cfg, key, val)
|
45
|
+
setter = "#{key}="
|
46
|
+
raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
|
47
|
+
|
48
|
+
cfg.public_send(setter, val)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|