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
|
@@ -15,6 +15,7 @@ module JetstreamBridge
|
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
17
|
# Safe column presence check that never boots a connection during class load.
|
|
18
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
18
19
|
def has_column?(name)
|
|
19
20
|
return false unless ar_connected?
|
|
20
21
|
|
|
@@ -22,6 +23,7 @@ module JetstreamBridge
|
|
|
22
23
|
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
|
23
24
|
false
|
|
24
25
|
end
|
|
26
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
25
27
|
|
|
26
28
|
def ar_connected?
|
|
27
29
|
ActiveRecord::Base.connected? && connection_pool.active_connection?
|
|
@@ -66,9 +68,7 @@ module JetstreamBridge
|
|
|
66
68
|
# ---- Defaults that do not require schema at load time ----
|
|
67
69
|
before_validation do
|
|
68
70
|
self.status ||= 'received' if self.class.has_column?(:status) && status.blank?
|
|
69
|
-
if self.class.has_column?(:received_at) && received_at.blank?
|
|
70
|
-
self.received_at ||= Time.now.utc
|
|
71
|
-
end
|
|
71
|
+
self.received_at ||= Time.now.utc if self.class.has_column?(:received_at) && received_at.blank?
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
# ---- Helpers ----
|
|
@@ -103,7 +103,7 @@ module JetstreamBridge
|
|
|
103
103
|
raise_missing_ar!('Inbox', method_name)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
def respond_to_missing?(
|
|
106
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
|
107
107
|
false
|
|
108
108
|
end
|
|
109
109
|
|
|
@@ -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
|
|
@@ -15,6 +15,7 @@ module JetstreamBridge
|
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
17
|
# Safe column presence check that never boots a connection during class load.
|
|
18
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
18
19
|
def has_column?(name)
|
|
19
20
|
return false unless ar_connected?
|
|
20
21
|
|
|
@@ -22,6 +23,7 @@ module JetstreamBridge
|
|
|
22
23
|
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
|
23
24
|
false
|
|
24
25
|
end
|
|
26
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
25
27
|
|
|
26
28
|
def ar_connected?
|
|
27
29
|
# Avoid creating a connection; rescue if pool isn't set yet.
|
|
@@ -111,7 +113,7 @@ module JetstreamBridge
|
|
|
111
113
|
raise_missing_ar!('Outbox', method_name)
|
|
112
114
|
end
|
|
113
115
|
|
|
114
|
-
def respond_to_missing?(
|
|
116
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
|
115
117
|
false
|
|
116
118
|
end
|
|
117
119
|
|
|
@@ -11,52 +11,71 @@ module JetstreamBridge
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def find_or_build(event_id)
|
|
14
|
-
ModelUtils.find_or_init_by_best(
|
|
14
|
+
record = ModelUtils.find_or_init_by_best(
|
|
15
15
|
@klass,
|
|
16
16
|
{ event_id: event_id },
|
|
17
17
|
{ dedup_key: event_id } # fallback if app uses a different unique column
|
|
18
18
|
)
|
|
19
|
+
|
|
20
|
+
# Lock the row to prevent concurrent processing
|
|
21
|
+
if record.persisted? && !record.new_record? && record.respond_to?(:lock!)
|
|
22
|
+
begin
|
|
23
|
+
record.lock!
|
|
24
|
+
rescue ActiveRecord::RecordNotFound
|
|
25
|
+
# Record was deleted between find and lock, create new
|
|
26
|
+
record = @klass.new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
record
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
def already_sent?(record)
|
|
22
|
-
record.respond_to?(:sent_at) && record.sent_at
|
|
34
|
+
(record.respond_to?(:sent_at) && record.sent_at) ||
|
|
35
|
+
(record.respond_to?(:status) && record.status == 'sent')
|
|
23
36
|
end
|
|
24
37
|
|
|
25
38
|
def persist_pre(record, subject, envelope)
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
ActiveRecord::Base.transaction do
|
|
40
|
+
now = Time.now.utc
|
|
41
|
+
event_id = envelope['event_id'].to_s
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
attrs = {
|
|
44
|
+
event_id: event_id,
|
|
45
|
+
subject: subject,
|
|
46
|
+
payload: ModelUtils.json_dump(envelope),
|
|
47
|
+
headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
|
|
48
|
+
status: 'publishing',
|
|
49
|
+
last_error: nil
|
|
50
|
+
}
|
|
51
|
+
attrs[:attempts] = 1 + (record.attempts || 0) if record.respond_to?(:attempts)
|
|
52
|
+
attrs[:enqueued_at] = (record.enqueued_at || now) if record.respond_to?(:enqueued_at)
|
|
53
|
+
attrs[:updated_at] = now if record.respond_to?(:updated_at)
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
|
56
|
+
record.save!
|
|
57
|
+
end
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
def persist_success(record)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
ActiveRecord::Base.transaction do
|
|
62
|
+
now = Time.now.utc
|
|
63
|
+
attrs = { status: 'sent' }
|
|
64
|
+
attrs[:sent_at] = now if record.respond_to?(:sent_at)
|
|
65
|
+
attrs[:updated_at] = now if record.respond_to?(:updated_at)
|
|
66
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
|
67
|
+
record.save!
|
|
68
|
+
end
|
|
52
69
|
end
|
|
53
70
|
|
|
54
71
|
def persist_failure(record, message)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
ActiveRecord::Base.transaction do
|
|
73
|
+
now = Time.now.utc
|
|
74
|
+
attrs = { status: 'failed', last_error: message }
|
|
75
|
+
attrs[:updated_at] = now if record.respond_to?(:updated_at)
|
|
76
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
|
77
|
+
record.save!
|
|
78
|
+
end
|
|
60
79
|
end
|
|
61
80
|
|
|
62
81
|
def persist_exception(record, error)
|
|
@@ -6,22 +6,15 @@ require_relative '../core/connection'
|
|
|
6
6
|
require_relative '../core/logging'
|
|
7
7
|
require_relative '../core/config'
|
|
8
8
|
require_relative '../core/model_utils'
|
|
9
|
+
require_relative '../core/retry_strategy'
|
|
9
10
|
require_relative 'outbox_repository'
|
|
10
11
|
|
|
11
12
|
module JetstreamBridge
|
|
12
13
|
# Publishes to "{env}.{app}.sync.{dest}".
|
|
13
14
|
class Publisher
|
|
14
|
-
|
|
15
|
-
RETRY_BACKOFFS = [0.25, 1.0].freeze
|
|
16
|
-
|
|
17
|
-
TRANSIENT_ERRORS = begin
|
|
18
|
-
errs = [NATS::IO::Timeout, NATS::IO::Error]
|
|
19
|
-
errs << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
|
|
20
|
-
errs.freeze
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def initialize
|
|
15
|
+
def initialize(retry_strategy: nil)
|
|
24
16
|
@jts = Connection.connect!
|
|
17
|
+
@retry_strategy = retry_strategy || PublisherRetryStrategy.new
|
|
25
18
|
end
|
|
26
19
|
|
|
27
20
|
# @return [Boolean]
|
|
@@ -33,7 +26,7 @@ module JetstreamBridge
|
|
|
33
26
|
if JetstreamBridge.config.use_outbox
|
|
34
27
|
publish_via_outbox(subject, envelope)
|
|
35
28
|
else
|
|
36
|
-
with_retries {
|
|
29
|
+
with_retries { publish_to_nats(subject, envelope) }
|
|
37
30
|
end
|
|
38
31
|
rescue StandardError => e
|
|
39
32
|
log_error(false, e)
|
|
@@ -47,7 +40,7 @@ module JetstreamBridge
|
|
|
47
40
|
raise ArgumentError, 'destination_app must be configured'
|
|
48
41
|
end
|
|
49
42
|
|
|
50
|
-
def
|
|
43
|
+
def publish_to_nats(subject, envelope)
|
|
51
44
|
headers = { 'nats-msg-id' => envelope['event_id'] }
|
|
52
45
|
|
|
53
46
|
ack = @jts.publish(subject, Oj.dump(envelope, mode: :compat), header: headers)
|
|
@@ -76,7 +69,7 @@ module JetstreamBridge
|
|
|
76
69
|
"Outbox model #{klass} is not an ActiveRecord model; publishing directly.",
|
|
77
70
|
tag: 'JetstreamBridge::Publisher'
|
|
78
71
|
)
|
|
79
|
-
return with_retries {
|
|
72
|
+
return with_retries { publish_to_nats(subject, envelope) }
|
|
80
73
|
end
|
|
81
74
|
|
|
82
75
|
repo = OutboxRepository.new(klass)
|
|
@@ -93,7 +86,7 @@ module JetstreamBridge
|
|
|
93
86
|
|
|
94
87
|
repo.persist_pre(record, subject, envelope)
|
|
95
88
|
|
|
96
|
-
ok = with_retries {
|
|
89
|
+
ok = with_retries { publish_to_nats(subject, envelope) }
|
|
97
90
|
ok ? repo.persist_success(record) : repo.persist_failure(record, 'Publish returned false')
|
|
98
91
|
ok
|
|
99
92
|
rescue StandardError => e
|
|
@@ -102,27 +95,11 @@ module JetstreamBridge
|
|
|
102
95
|
end
|
|
103
96
|
# ---- /Outbox path ----
|
|
104
97
|
|
|
105
|
-
# Retry
|
|
106
|
-
def with_retries
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
rescue *TRANSIENT_ERRORS => e
|
|
111
|
-
attempts += 1
|
|
112
|
-
return log_error(false, e) if attempts > retries
|
|
113
|
-
|
|
114
|
-
backoff(attempts, e)
|
|
115
|
-
retry
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def backoff(attempts, error)
|
|
120
|
-
delay = RETRY_BACKOFFS[attempts - 1] || RETRY_BACKOFFS.last
|
|
121
|
-
Logging.warn(
|
|
122
|
-
"Publish retry #{attempts} after #{error.class}: #{error.message}",
|
|
123
|
-
tag: 'JetstreamBridge::Publisher'
|
|
124
|
-
)
|
|
125
|
-
sleep delay
|
|
98
|
+
# Retry using strategy pattern
|
|
99
|
+
def with_retries
|
|
100
|
+
@retry_strategy.execute(context: 'Publisher') { yield }
|
|
101
|
+
rescue RetryStrategy::RetryExhausted => e
|
|
102
|
+
log_error(false, e)
|
|
126
103
|
end
|
|
127
104
|
|
|
128
105
|
def log_error(val, exc)
|
|
@@ -1,17 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'core/model_codec_setup'
|
|
4
|
+
require_relative 'core/logging'
|
|
4
5
|
|
|
5
6
|
module JetstreamBridge
|
|
6
7
|
class Railtie < ::Rails::Railtie
|
|
7
|
-
|
|
8
|
+
# Set up logger to use Rails.logger by default
|
|
9
|
+
initializer 'jetstream_bridge.logger', before: :initialize_logger do
|
|
10
|
+
JetstreamBridge.configure do |config|
|
|
11
|
+
config.logger ||= Rails.logger if defined?(Rails.logger)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Load ActiveRecord model tweaks after ActiveRecord is loaded
|
|
16
|
+
initializer 'jetstream_bridge.active_record', after: 'active_record.initialize_database' do
|
|
8
17
|
ActiveSupport.on_load(:active_record) do
|
|
9
18
|
ActiveSupport::Reloader.to_prepare { JetstreamBridge::ModelCodecSetup.apply! }
|
|
10
19
|
end
|
|
11
20
|
end
|
|
12
21
|
|
|
22
|
+
# Validate configuration in development/test
|
|
23
|
+
initializer 'jetstream_bridge.validate_config', after: :load_config_initializers do |app|
|
|
24
|
+
if Rails.env.development? || Rails.env.test?
|
|
25
|
+
app.config.after_initialize do
|
|
26
|
+
begin
|
|
27
|
+
JetstreamBridge.config.validate! if JetstreamBridge.config.destination_app
|
|
28
|
+
rescue JetstreamBridge::ConfigurationError => e
|
|
29
|
+
Rails.logger.warn "[JetStream Bridge] Configuration warning: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Add console helper methods
|
|
36
|
+
console do
|
|
37
|
+
Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
|
|
38
|
+
Rails.logger.info "[JetStream Bridge] Use JetstreamBridge.health_check to check status"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Load rake tasks
|
|
13
42
|
rake_tasks do
|
|
14
43
|
load File.expand_path('tasks/install.rake', __dir__)
|
|
15
44
|
end
|
|
45
|
+
|
|
46
|
+
# Add generators
|
|
47
|
+
generators do
|
|
48
|
+
require 'generators/jetstream_bridge/health_check/health_check_generator'
|
|
49
|
+
end
|
|
16
50
|
end
|
|
17
51
|
end
|
|
@@ -7,4 +7,103 @@ namespace :jetstream_bridge do
|
|
|
7
7
|
Rails::Generators.invoke('jetstream_bridge:install', [], behavior: :invoke, destination_root: Rails.root.to_s)
|
|
8
8
|
puts '[jetstream_bridge] Done.'
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
desc 'Check health and connection status'
|
|
12
|
+
task health: :environment do
|
|
13
|
+
require 'json'
|
|
14
|
+
|
|
15
|
+
puts '=' * 70
|
|
16
|
+
puts 'JetStream Bridge Health Check'
|
|
17
|
+
puts '=' * 70
|
|
18
|
+
|
|
19
|
+
health = JetstreamBridge.health_check
|
|
20
|
+
|
|
21
|
+
puts "\nStatus: #{health[:healthy] ? '✓ HEALTHY' : '✗ UNHEALTHY'}"
|
|
22
|
+
puts "NATS Connected: #{health[:nats_connected] ? 'Yes' : 'No'}"
|
|
23
|
+
puts "Connected At: #{health[:connected_at] || 'N/A'}"
|
|
24
|
+
puts "Version: #{health[:version]}"
|
|
25
|
+
|
|
26
|
+
if health[:stream]
|
|
27
|
+
puts "\nStream:"
|
|
28
|
+
puts " Name: #{health[:stream][:name]}"
|
|
29
|
+
puts " Exists: #{health[:stream][:exists] ? 'Yes' : 'No'}"
|
|
30
|
+
if health[:stream][:subjects]
|
|
31
|
+
puts " Subjects: #{health[:stream][:subjects].join(', ')}"
|
|
32
|
+
puts " Messages: #{health[:stream][:messages]}"
|
|
33
|
+
end
|
|
34
|
+
puts " Error: #{health[:stream][:error]}" if health[:stream][:error]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if health[:config]
|
|
38
|
+
puts "\nConfiguration:"
|
|
39
|
+
puts " Environment: #{health[:config][:env]}"
|
|
40
|
+
puts " App Name: #{health[:config][:app_name]}"
|
|
41
|
+
puts " Destination: #{health[:config][:destination_app] || 'NOT SET'}"
|
|
42
|
+
puts " Outbox: #{health[:config][:use_outbox] ? 'Enabled' : 'Disabled'}"
|
|
43
|
+
puts " Inbox: #{health[:config][:use_inbox] ? 'Enabled' : 'Disabled'}"
|
|
44
|
+
puts " DLQ: #{health[:config][:use_dlq] ? 'Enabled' : 'Disabled'}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if health[:error]
|
|
48
|
+
puts "\n#{' ERROR '.center(70, '=')}"
|
|
49
|
+
puts health[:error]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
puts '=' * 70
|
|
53
|
+
|
|
54
|
+
exit(health[:healthy] ? 0 : 1)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc 'Validate configuration'
|
|
58
|
+
task validate: :environment do
|
|
59
|
+
puts '[jetstream_bridge] Validating configuration...'
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
JetstreamBridge.config.validate!
|
|
63
|
+
puts '✓ Configuration is valid'
|
|
64
|
+
puts "\nCurrent settings:"
|
|
65
|
+
puts " Environment: #{JetstreamBridge.config.env}"
|
|
66
|
+
puts " App Name: #{JetstreamBridge.config.app_name}"
|
|
67
|
+
puts " Destination: #{JetstreamBridge.config.destination_app}"
|
|
68
|
+
puts " Stream: #{JetstreamBridge.config.stream_name}"
|
|
69
|
+
puts " Source Subject: #{JetstreamBridge.config.source_subject}"
|
|
70
|
+
puts " Destination Subject: #{JetstreamBridge.config.destination_subject}"
|
|
71
|
+
exit 0
|
|
72
|
+
rescue JetstreamBridge::ConfigurationError => e
|
|
73
|
+
puts "✗ Configuration error: #{e.message}"
|
|
74
|
+
exit 1
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
desc 'Show debug information'
|
|
79
|
+
task debug: :environment do
|
|
80
|
+
JetstreamBridge::DebugHelper.debug_info
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc 'Test connection to NATS'
|
|
84
|
+
task test_connection: :environment do
|
|
85
|
+
puts '[jetstream_bridge] Testing NATS connection...'
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
jts = JetstreamBridge.ensure_topology!
|
|
89
|
+
puts '✓ Successfully connected to NATS'
|
|
90
|
+
puts "✓ JetStream is available"
|
|
91
|
+
puts "✓ Stream topology ensured"
|
|
92
|
+
|
|
93
|
+
# Check if we can get account info
|
|
94
|
+
info = jts.account_info
|
|
95
|
+
puts "\nAccount Info:"
|
|
96
|
+
puts " Memory: #{info.memory}"
|
|
97
|
+
puts " Storage: #{info.storage}"
|
|
98
|
+
puts " Streams: #{info.streams}"
|
|
99
|
+
puts " Consumers: #{info.consumers}"
|
|
100
|
+
|
|
101
|
+
exit 0
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
puts "✗ Connection failed: #{e.message}"
|
|
104
|
+
puts "\nBacktrace:" if ENV['VERBOSE']
|
|
105
|
+
puts e.backtrace.first(10).map { |line| " #{line}" }.join("\n") if ENV['VERBOSE']
|
|
106
|
+
exit 1
|
|
107
|
+
end
|
|
108
|
+
end
|
|
10
109
|
end
|
|
@@ -55,11 +55,25 @@ module JetstreamBridge
|
|
|
55
55
|
def list_stream_names(jts)
|
|
56
56
|
names = []
|
|
57
57
|
offset = 0
|
|
58
|
+
max_iterations = 100 # Safety limit to prevent infinite loops
|
|
59
|
+
iterations = 0
|
|
60
|
+
|
|
58
61
|
loop do
|
|
62
|
+
iterations += 1
|
|
63
|
+
if iterations > max_iterations
|
|
64
|
+
Logging.warn(
|
|
65
|
+
"Stream listing exceeded max iterations (#{max_iterations}), returning #{names.size} streams",
|
|
66
|
+
tag: 'JetstreamBridge::OverlapGuard'
|
|
67
|
+
)
|
|
68
|
+
break
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
resp = js_api_request(jts, '$JS.API.STREAM.NAMES', { offset: offset })
|
|
60
72
|
batch = Array(resp['streams']).filter_map { |h| h['name'] }
|
|
61
73
|
names.concat(batch)
|
|
62
|
-
|
|
74
|
+
total = resp['total'].to_i
|
|
75
|
+
|
|
76
|
+
break if names.size >= total || batch.empty?
|
|
63
77
|
|
|
64
78
|
offset = names.size
|
|
65
79
|
end
|