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.
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # JetstreamBridge
4
+ #
5
+ # Version constant for the gem.
6
+ module JetstreamBridge
7
+ VERSION = '1.4.0'
8
+ 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