jetstream_bridge 1.6.0 → 1.8.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/.idea/dictionaries/project.xml +1 -0
- data/.idea/jetstream_bridge.iml +6 -1
- data/.rubocop.yml +102 -0
- data/Gemfile.lock +1 -7
- data/README.md +76 -32
- data/jetstream_bridge.gemspec +9 -10
- data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +16 -0
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +24 -0
- data/lib/generators/jetstream_bridge/install/install_generator.rb +19 -0
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +44 -0
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +24 -0
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +21 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +103 -0
- data/lib/jetstream_bridge/{consumer_config.rb → consumer/consumer_config.rb} +3 -3
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +50 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +51 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +102 -0
- data/lib/jetstream_bridge/{message_processor.rb → consumer/message_processor.rb} +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +91 -0
- data/lib/jetstream_bridge/{connection.rb → core/connection.rb} +1 -1
- data/lib/jetstream_bridge/models/inbox_event.rb +101 -0
- data/lib/jetstream_bridge/models/outbox_event.rb +100 -0
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
- data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +10 -58
- data/lib/jetstream_bridge/railtie.rb +37 -0
- data/lib/jetstream_bridge/tasks/install.rake +10 -0
- data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
- data/lib/jetstream_bridge/topology/stream.rb +129 -0
- data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +35 -23
- metadata +49 -50
- data/lib/jetstream_bridge/consumer.rb +0 -232
- data/lib/jetstream_bridge/dlq.rb +0 -24
- data/lib/jetstream_bridge/inbox_event.rb +0 -46
- data/lib/jetstream_bridge/outbox_event.rb +0 -60
- data/lib/jetstream_bridge/stream.rb +0 -114
- /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
- /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
- /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
- /data/lib/jetstream_bridge/{model_utils.rb → core/model_utils.rb} +0 -0
- /data/lib/jetstream_bridge/{subject_matcher.rb → topology/subject_matcher.rb} +0 -0
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative 'duration'
|
4
|
-
require_relative '
|
5
|
-
require_relative '
|
3
|
+
require_relative '../core/duration'
|
4
|
+
require_relative '../core/config'
|
5
|
+
require_relative '../core/logging'
|
6
6
|
|
7
7
|
module JetstreamBridge
|
8
8
|
# Consumer configuration helpers.
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module JetstreamBridge
|
6
|
+
# Immutable value object for a single NATS message.
|
7
|
+
class InboxMessage
|
8
|
+
attr_reader :msg, :seq, :deliveries, :stream, :subject, :headers, :body, :raw, :event_id, :now
|
9
|
+
|
10
|
+
def self.from_nats(m)
|
11
|
+
meta = (m.respond_to?(:metadata) && m.metadata) || nil
|
12
|
+
seq = meta.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
|
13
|
+
deliveries = meta.respond_to?(:num_delivered) ? meta.num_delivered : nil
|
14
|
+
stream = meta.respond_to?(:stream) ? meta.stream : nil
|
15
|
+
subject = m.subject.to_s
|
16
|
+
|
17
|
+
headers = {}
|
18
|
+
(m.header || {}).each { |k, v| headers[k.to_s.downcase] = v }
|
19
|
+
|
20
|
+
raw = m.data
|
21
|
+
body = begin
|
22
|
+
JSON.parse(raw)
|
23
|
+
rescue StandardError
|
24
|
+
{}
|
25
|
+
end
|
26
|
+
|
27
|
+
id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
|
28
|
+
id = "seq:#{seq}" if id.empty?
|
29
|
+
|
30
|
+
new(m, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc)
|
31
|
+
end
|
32
|
+
|
33
|
+
def initialize(m, seq, deliveries, stream, subject, headers, body, raw, event_id, now)
|
34
|
+
@msg = m
|
35
|
+
@seq = seq
|
36
|
+
@deliveries = deliveries
|
37
|
+
@stream = stream
|
38
|
+
@subject = subject
|
39
|
+
@headers = headers
|
40
|
+
@body = body
|
41
|
+
@raw = raw
|
42
|
+
@event_id = event_id
|
43
|
+
@now = now
|
44
|
+
end
|
45
|
+
|
46
|
+
def body_for_store
|
47
|
+
body.empty? ? raw : body
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../..//core/logging'
|
4
|
+
require_relative '../../core/model_utils'
|
5
|
+
require_relative 'inbox_message'
|
6
|
+
require_relative 'inbox_repository'
|
7
|
+
|
8
|
+
module JetstreamBridge
|
9
|
+
# Orchestrates AR-backed inbox processing.
|
10
|
+
class InboxProcessor
|
11
|
+
def initialize(message_processor)
|
12
|
+
@processor = message_processor
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [true,false] processed?
|
16
|
+
def process(m)
|
17
|
+
klass = ModelUtils.constantize(JetstreamBridge.config.inbox_model)
|
18
|
+
return process_direct(m, klass) unless ModelUtils.ar_class?(klass)
|
19
|
+
|
20
|
+
msg = InboxMessage.from_nats(m)
|
21
|
+
repo = InboxRepository.new(klass)
|
22
|
+
record = repo.find_or_build(msg)
|
23
|
+
|
24
|
+
if repo.already_processed?(record)
|
25
|
+
m.ack
|
26
|
+
return true
|
27
|
+
end
|
28
|
+
|
29
|
+
repo.persist_pre(record, msg)
|
30
|
+
@processor.handle_message(m)
|
31
|
+
repo.persist_post(record)
|
32
|
+
true
|
33
|
+
rescue => e
|
34
|
+
repo.persist_failure(record, e) if defined?(repo) && defined?(record)
|
35
|
+
Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
|
36
|
+
tag: 'JetstreamBridge::Consumer')
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def process_direct(m, klass)
|
43
|
+
unless ModelUtils.ar_class?(klass)
|
44
|
+
Logging.warn("Inbox model #{klass} is not an ActiveRecord model; processing directly.",
|
45
|
+
tag: 'JetstreamBridge::Consumer')
|
46
|
+
end
|
47
|
+
@processor.handle_message(m)
|
48
|
+
true
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../core/model_utils'
|
4
|
+
require_relative '../../core/logging'
|
5
|
+
|
6
|
+
module JetstreamBridge
|
7
|
+
# AR-facing operations for inbox rows (find/build/persist).
|
8
|
+
class InboxRepository
|
9
|
+
def initialize(klass)
|
10
|
+
@klass = klass
|
11
|
+
end
|
12
|
+
|
13
|
+
def find_or_build(msg)
|
14
|
+
if ModelUtils.has_columns?(@klass, :event_id)
|
15
|
+
@klass.find_or_initialize_by(event_id: msg.event_id)
|
16
|
+
elsif ModelUtils.has_columns?(@klass, :stream_seq)
|
17
|
+
@klass.find_or_initialize_by(stream_seq: msg.seq)
|
18
|
+
else
|
19
|
+
@klass.new
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def already_processed?(record)
|
24
|
+
record.respond_to?(:processed_at) && record.processed_at
|
25
|
+
end
|
26
|
+
|
27
|
+
def persist_pre(record, msg)
|
28
|
+
ModelUtils.assign_known_attrs(record, {
|
29
|
+
event_id: (if ModelUtils.has_columns?(@klass,
|
30
|
+
:event_id)
|
31
|
+
msg.event_id
|
32
|
+
end),
|
33
|
+
subject: msg.subject,
|
34
|
+
payload: ModelUtils.json_dump(msg.body_for_store),
|
35
|
+
headers: ModelUtils.json_dump(msg.headers),
|
36
|
+
stream: (if ModelUtils.has_columns?(@klass,
|
37
|
+
:stream)
|
38
|
+
msg.stream
|
39
|
+
end),
|
40
|
+
stream_seq: (if ModelUtils.has_columns?(@klass,
|
41
|
+
:stream_seq)
|
42
|
+
msg.seq
|
43
|
+
end),
|
44
|
+
deliveries: (if ModelUtils.has_columns?(@klass,
|
45
|
+
:deliveries)
|
46
|
+
msg.deliveries
|
47
|
+
end),
|
48
|
+
status: 'processing',
|
49
|
+
last_error: nil,
|
50
|
+
received_at: (if ModelUtils.has_columns?(@klass,
|
51
|
+
:received_at)
|
52
|
+
record.received_at || msg.now
|
53
|
+
end),
|
54
|
+
updated_at: (if ModelUtils.has_columns?(@klass,
|
55
|
+
:updated_at)
|
56
|
+
msg.now
|
57
|
+
end)
|
58
|
+
})
|
59
|
+
record.save!
|
60
|
+
end
|
61
|
+
|
62
|
+
def persist_post(record)
|
63
|
+
now = Time.now.utc
|
64
|
+
ModelUtils.assign_known_attrs(record, {
|
65
|
+
status: 'processed',
|
66
|
+
processed_at: (if ModelUtils.has_columns?(@klass,
|
67
|
+
:processed_at)
|
68
|
+
now
|
69
|
+
end),
|
70
|
+
updated_at: (if ModelUtils.has_columns?(@klass,
|
71
|
+
:updated_at)
|
72
|
+
now
|
73
|
+
end)
|
74
|
+
})
|
75
|
+
record.save!
|
76
|
+
end
|
77
|
+
|
78
|
+
def persist_failure(record, error)
|
79
|
+
return unless record
|
80
|
+
|
81
|
+
now = Time.now.utc
|
82
|
+
ModelUtils.assign_known_attrs(record, {
|
83
|
+
status: (if ModelUtils.has_columns?(@klass,
|
84
|
+
:status)
|
85
|
+
'failed'
|
86
|
+
end),
|
87
|
+
last_error: (if ModelUtils.has_columns?(@klass,
|
88
|
+
:last_error)
|
89
|
+
"#{error.class}: #{error.message}"
|
90
|
+
end),
|
91
|
+
updated_at: (if ModelUtils.has_columns?(@klass,
|
92
|
+
:updated_at)
|
93
|
+
now
|
94
|
+
end)
|
95
|
+
})
|
96
|
+
record.save!
|
97
|
+
rescue StandardError => e
|
98
|
+
Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
|
99
|
+
tag: 'JetstreamBridge::Consumer')
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../core/logging'
|
4
|
+
require_relative '../consumer/consumer_config'
|
5
|
+
|
6
|
+
module JetstreamBridge
|
7
|
+
# Encapsulates durable ensure + subscribe for a consumer.
|
8
|
+
class SubscriptionManager
|
9
|
+
def initialize(jts, durable, cfg = JetstreamBridge.config)
|
10
|
+
@jts = jts
|
11
|
+
@durable = durable
|
12
|
+
@cfg = cfg
|
13
|
+
end
|
14
|
+
|
15
|
+
def stream_name
|
16
|
+
@cfg.stream_name
|
17
|
+
end
|
18
|
+
|
19
|
+
def filter_subject
|
20
|
+
@cfg.destination_subject
|
21
|
+
end
|
22
|
+
|
23
|
+
def desired_consumer_cfg
|
24
|
+
ConsumerConfig.consumer_config(@durable, filter_subject)
|
25
|
+
end
|
26
|
+
|
27
|
+
def ensure_consumer!
|
28
|
+
info = consumer_info_or_nil
|
29
|
+
return create_consumer! unless info
|
30
|
+
return log_consumer_ok if consumer_matches?(info)
|
31
|
+
|
32
|
+
recreate_consumer!
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def consumer_info_or_nil
|
38
|
+
@jts.consumer_info(stream_name, @durable)
|
39
|
+
rescue NATS::JetStream::Error
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
|
43
|
+
def consumer_matches?(info)
|
44
|
+
cfg = info.config
|
45
|
+
have = (cfg.respond_to?(:filter_subject) ? cfg.filter_subject : cfg[:filter_subject]).to_s
|
46
|
+
want = desired_consumer_cfg[:filter_subject].to_s
|
47
|
+
have == want
|
48
|
+
end
|
49
|
+
|
50
|
+
def recreate_consumer!
|
51
|
+
Logging.warn(
|
52
|
+
"Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
|
53
|
+
tag: 'JetstreamBridge::Consumer'
|
54
|
+
)
|
55
|
+
safe_delete_consumer
|
56
|
+
create_consumer!
|
57
|
+
end
|
58
|
+
|
59
|
+
def create_consumer!
|
60
|
+
@jts.add_consumer(stream_name, **desired_consumer_cfg)
|
61
|
+
Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
|
62
|
+
tag: 'JetstreamBridge::Consumer')
|
63
|
+
end
|
64
|
+
|
65
|
+
def log_consumer_ok
|
66
|
+
Logging.info("Consumer #{@durable} exists with desired config.",
|
67
|
+
tag: 'JetstreamBridge::Consumer')
|
68
|
+
end
|
69
|
+
|
70
|
+
def safe_delete_consumer
|
71
|
+
@jts.delete_consumer(stream_name, @durable)
|
72
|
+
rescue NATS::JetStream::Error => e
|
73
|
+
Logging.warn("Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
|
74
|
+
tag: 'JetstreamBridge::Consumer')
|
75
|
+
end
|
76
|
+
def subscribe!
|
77
|
+
@jts.pull_subscribe(
|
78
|
+
filter_subject,
|
79
|
+
@durable,
|
80
|
+
stream: stream_name,
|
81
|
+
config: ConsumerConfig.subscribe_config
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
85
|
+
def consumer_mismatch?(info, desired_cfg)
|
86
|
+
cfg = info.config
|
87
|
+
(cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
|
88
|
+
desired_cfg[:filter_subject].to_s
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
rescue LoadError
|
6
|
+
# No-op; shim defined below.
|
7
|
+
end
|
8
|
+
|
9
|
+
module JetstreamBridge
|
10
|
+
if defined?(ActiveRecord::Base)
|
11
|
+
class InboxEvent < ActiveRecord::Base
|
12
|
+
self.table_name = 'jetstream_inbox_events'
|
13
|
+
|
14
|
+
class << self
|
15
|
+
def has_column?(name)
|
16
|
+
return false unless ar_connected?
|
17
|
+
|
18
|
+
connection.schema_cache.columns_hash(table_name).key?(name.to_s)
|
19
|
+
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
20
|
+
false
|
21
|
+
end
|
22
|
+
|
23
|
+
def ar_connected?
|
24
|
+
ActiveRecord::Base.connected? && connection_pool.active_connection?
|
25
|
+
rescue StandardError
|
26
|
+
false
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Validations guarded by safe checks
|
31
|
+
if_condition = -> { self.class.has_column?(:event_id) }
|
32
|
+
unless_condition = -> { !self.class.has_column?(:event_id) }
|
33
|
+
|
34
|
+
validates :event_id, presence: true, uniqueness: true, if: if_condition
|
35
|
+
|
36
|
+
with_options if: unless_condition do
|
37
|
+
validates :stream_seq, presence: true, if: -> { self.class.has_column?(:stream_seq) }
|
38
|
+
# uniqueness scoped to stream when both present
|
39
|
+
if has_column?(:stream) && has_column?(:stream_seq)
|
40
|
+
validates :stream_seq, uniqueness: { scope: :stream }
|
41
|
+
elsif has_column?(:stream_seq)
|
42
|
+
validates :stream_seq, uniqueness: true
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
validates :subject, presence: true, if: -> { self.class.has_column?(:subject) }
|
47
|
+
|
48
|
+
before_validation do
|
49
|
+
self.status ||= 'received' if self.class.has_column?(:status) && status.blank?
|
50
|
+
if self.class.has_column?(:received_at) && received_at.blank?
|
51
|
+
self.received_at ||= Time.now.utc
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def processed?
|
56
|
+
if self.class.has_column?(:processed_at)
|
57
|
+
processed_at.present?
|
58
|
+
elsif self.class.has_column?(:status)
|
59
|
+
status == 'processed'
|
60
|
+
else
|
61
|
+
false
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def payload_hash
|
66
|
+
v = self[:payload]
|
67
|
+
case v
|
68
|
+
when String then begin
|
69
|
+
JSON.parse(v)
|
70
|
+
rescue StandardError
|
71
|
+
{}
|
72
|
+
end
|
73
|
+
when Hash then v
|
74
|
+
else v.respond_to?(:as_json) ? v.as_json : {}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
else
|
79
|
+
class InboxEvent
|
80
|
+
class << self
|
81
|
+
def method_missing(method_name, *_args, &_block)
|
82
|
+
raise_missing_ar!('Inbox', method_name)
|
83
|
+
end
|
84
|
+
|
85
|
+
def respond_to_missing?(_m, _p = false)
|
86
|
+
false
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def raise_missing_ar!(which, method_name)
|
92
|
+
raise(
|
93
|
+
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
94
|
+
'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
|
95
|
+
'`gem "activerecord"` to your Gemfile.'
|
96
|
+
)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
rescue LoadError
|
6
|
+
# No-op; shim defined below.
|
7
|
+
end
|
8
|
+
|
9
|
+
module JetstreamBridge
|
10
|
+
if defined?(ActiveRecord::Base)
|
11
|
+
class OutboxEvent < ActiveRecord::Base
|
12
|
+
self.table_name = 'jetstream_outbox_events'
|
13
|
+
|
14
|
+
class << self
|
15
|
+
# Safe column presence check that never boots a connection during class load.
|
16
|
+
def has_column?(name)
|
17
|
+
return false unless ar_connected?
|
18
|
+
connection.schema_cache.columns_hash(table_name).key?(name.to_s)
|
19
|
+
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
20
|
+
false
|
21
|
+
end
|
22
|
+
|
23
|
+
def ar_connected?
|
24
|
+
# Use connected? and avoid retrieving/creating a connection
|
25
|
+
ActiveRecord::Base.connected? && connection_pool.active_connection?
|
26
|
+
rescue
|
27
|
+
false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# ---- Validations guarded by safe schema checks (evaluated at validation time) ----
|
32
|
+
validates :payload, presence: true, if: -> { self.class.has_column?(:payload) }
|
33
|
+
|
34
|
+
with_options if: -> { self.class.has_column?(:event_id) } do
|
35
|
+
validates :event_id, presence: true, uniqueness: true
|
36
|
+
end
|
37
|
+
|
38
|
+
with_options if: -> { !self.class.has_column?(:event_id) } do
|
39
|
+
validates :resource_type, presence: true, if: -> { self.class.has_column?(:resource_type) }
|
40
|
+
validates :resource_id, presence: true, if: -> { self.class.has_column?(:resource_id) }
|
41
|
+
validates :event_type, presence: true, if: -> { self.class.has_column?(:event_type) }
|
42
|
+
end
|
43
|
+
|
44
|
+
validates :subject, presence: true, if: -> { self.class.has_column?(:subject) }
|
45
|
+
|
46
|
+
validates :attempts, numericality: { only_integer: true, greater_than_or_equal_to: 0 },
|
47
|
+
if: -> { self.class.has_column?(:attempts) }
|
48
|
+
|
49
|
+
# Defaults that do not require schema at load time
|
50
|
+
before_validation do
|
51
|
+
now = Time.now.utc
|
52
|
+
self.status ||= 'pending' if self.class.has_column?(:status) && status.blank?
|
53
|
+
self.enqueued_at ||= now if self.class.has_column?(:enqueued_at) && enqueued_at.blank?
|
54
|
+
self.attempts = 0 if self.class.has_column?(:attempts) && attempts.nil?
|
55
|
+
end
|
56
|
+
|
57
|
+
# Helpers
|
58
|
+
def mark_sent!
|
59
|
+
now = Time.now.utc
|
60
|
+
self.status = 'sent' if self.class.has_column?(:status)
|
61
|
+
self.sent_at = now if self.class.has_column?(:sent_at)
|
62
|
+
save!
|
63
|
+
end
|
64
|
+
|
65
|
+
def mark_failed!(err_msg)
|
66
|
+
self.status = 'failed' if self.class.has_column?(:status)
|
67
|
+
self.last_error = err_msg if self.class.has_column?(:last_error)
|
68
|
+
save!
|
69
|
+
end
|
70
|
+
|
71
|
+
def payload_hash
|
72
|
+
v = self[:payload]
|
73
|
+
case v
|
74
|
+
when String then JSON.parse(v) rescue {}
|
75
|
+
when Hash then v
|
76
|
+
else v.respond_to?(:as_json) ? v.as_json : {}
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
else
|
81
|
+
# Shim: loud failure if AR isn't present but someone calls the model.
|
82
|
+
class OutboxEvent
|
83
|
+
class << self
|
84
|
+
def method_missing(method_name, *_args, &_block)
|
85
|
+
raise_missing_ar!('Outbox', method_name)
|
86
|
+
end
|
87
|
+
def respond_to_missing?(_m, _p = false) = false
|
88
|
+
|
89
|
+
private
|
90
|
+
def raise_missing_ar!(which, method_name)
|
91
|
+
raise(
|
92
|
+
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
93
|
+
'Enable `use_outbox` only in apps with ActiveRecord, or add ' \
|
94
|
+
'`gem "activerecord"` to your Gemfile.'
|
95
|
+
)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../core/model_utils'
|
4
|
+
require_relative '../core/logging'
|
5
|
+
|
6
|
+
module JetstreamBridge
|
7
|
+
# Encapsulates AR-backed outbox persistence operations.
|
8
|
+
class OutboxRepository
|
9
|
+
def initialize(klass)
|
10
|
+
@klass = klass
|
11
|
+
end
|
12
|
+
|
13
|
+
def find_or_build(event_id)
|
14
|
+
ModelUtils.find_or_init_by_best(
|
15
|
+
@klass,
|
16
|
+
{ event_id: event_id },
|
17
|
+
{ dedup_key: event_id } # fallback if app uses a different unique column
|
18
|
+
)
|
19
|
+
end
|
20
|
+
|
21
|
+
def already_sent?(record)
|
22
|
+
record.respond_to?(:sent_at) && record.sent_at
|
23
|
+
end
|
24
|
+
|
25
|
+
def persist_pre(record, subject, envelope)
|
26
|
+
now = Time.now.utc
|
27
|
+
event_id = envelope['event_id'].to_s
|
28
|
+
|
29
|
+
attrs = {
|
30
|
+
event_id: event_id,
|
31
|
+
subject: subject,
|
32
|
+
payload: ModelUtils.json_dump(envelope),
|
33
|
+
headers: ModelUtils.json_dump({ 'Nats-Msg-Id' => event_id }),
|
34
|
+
status: 'publishing',
|
35
|
+
last_error: nil
|
36
|
+
}
|
37
|
+
attrs[:attempts] = 1 + (record.attempts || 0) if record.respond_to?(:attempts)
|
38
|
+
attrs[:enqueued_at]= (record.enqueued_at || now) if record.respond_to?(:enqueued_at)
|
39
|
+
attrs[:updated_at] = now if record.respond_to?(:updated_at)
|
40
|
+
|
41
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
42
|
+
record.save!
|
43
|
+
end
|
44
|
+
|
45
|
+
def persist_success(record)
|
46
|
+
now = Time.now.utc
|
47
|
+
attrs = { status: 'sent' }
|
48
|
+
attrs[:sent_at] = now if record.respond_to?(:sent_at)
|
49
|
+
attrs[:updated_at]= now if record.respond_to?(:updated_at)
|
50
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
51
|
+
record.save!
|
52
|
+
end
|
53
|
+
|
54
|
+
def persist_failure(record, message)
|
55
|
+
now = Time.now.utc
|
56
|
+
attrs = { status: 'failed', last_error: message }
|
57
|
+
attrs[:updated_at] = now if record.respond_to?(:updated_at)
|
58
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
59
|
+
record.save!
|
60
|
+
end
|
61
|
+
|
62
|
+
def persist_exception(record, error)
|
63
|
+
return unless record
|
64
|
+
persist_failure(record, "#{error.class}: #{error.message}")
|
65
|
+
rescue => e2
|
66
|
+
Logging.warn("Failed to persist outbox failure: #{e2.class}: #{e2.message}",
|
67
|
+
tag: 'JetstreamBridge::Publisher')
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -2,10 +2,10 @@
|
|
2
2
|
|
3
3
|
require 'json'
|
4
4
|
require 'securerandom'
|
5
|
-
require_relative 'connection'
|
6
|
-
require_relative 'logging'
|
7
|
-
require_relative 'config'
|
8
|
-
require_relative 'model_utils'
|
5
|
+
require_relative '../core/connection'
|
6
|
+
require_relative '../core/logging'
|
7
|
+
require_relative '../core/config'
|
8
|
+
require_relative '../core/model_utils'
|
9
9
|
|
10
10
|
module JetstreamBridge
|
11
11
|
# Publishes to "{env}.data.sync.{app}.{dest}".
|
@@ -63,71 +63,23 @@ module JetstreamBridge
|
|
63
63
|
return with_retries { do_publish(subject, envelope) }
|
64
64
|
end
|
65
65
|
|
66
|
-
|
66
|
+
repo = OutboxRepository.new(klass)
|
67
67
|
event_id = envelope['event_id'].to_s
|
68
|
+
record = repo.find_or_build(event_id)
|
68
69
|
|
69
|
-
|
70
|
-
klass,
|
71
|
-
{ event_id: event_id },
|
72
|
-
# Fallback key if app uses a different unique column:
|
73
|
-
{ dedup_key: event_id }
|
74
|
-
)
|
75
|
-
|
76
|
-
# If already sent, do nothing
|
77
|
-
if record.respond_to?(:sent_at) && record.sent_at
|
70
|
+
if repo.already_sent?(record)
|
78
71
|
Logging.info("Outbox already sent event_id=#{event_id}; skipping publish.",
|
79
72
|
tag: 'JetstreamBridge::Publisher')
|
80
73
|
return true
|
81
74
|
end
|
82
75
|
|
83
|
-
|
84
|
-
ModelUtils.assign_known_attrs(record, {
|
85
|
-
event_id: event_id,
|
86
|
-
subject: subject,
|
87
|
-
payload: ModelUtils.json_dump(envelope),
|
88
|
-
headers: ModelUtils.json_dump({ 'Nats-Msg-Id' => event_id }),
|
89
|
-
status: 'publishing',
|
90
|
-
attempts: (record.respond_to?(:attempts) ? (record.attempts || 0) + 1 : nil),
|
91
|
-
last_error: nil,
|
92
|
-
enqueued_at: (record.respond_to?(:enqueued_at) ? (record.enqueued_at || now) : nil),
|
93
|
-
updated_at: (record.respond_to?(:updated_at) ? now : nil)
|
94
|
-
})
|
95
|
-
record.save!
|
76
|
+
repo.persist_pre(record, subject, envelope)
|
96
77
|
|
97
78
|
ok = with_retries { do_publish(subject, envelope) }
|
98
|
-
|
99
|
-
if ok
|
100
|
-
ModelUtils.assign_known_attrs(record, {
|
101
|
-
status: 'sent',
|
102
|
-
sent_at: (record.respond_to?(:sent_at) ? now : nil),
|
103
|
-
updated_at: (record.respond_to?(:updated_at) ? now : nil)
|
104
|
-
})
|
105
|
-
record.save!
|
106
|
-
else
|
107
|
-
ModelUtils.assign_known_attrs(record, {
|
108
|
-
status: 'failed',
|
109
|
-
last_error: 'Publish returned false',
|
110
|
-
updated_at: (record.respond_to?(:updated_at) ? now : nil)
|
111
|
-
})
|
112
|
-
record.save!
|
113
|
-
end
|
114
|
-
|
79
|
+
ok ? repo.persist_success(record) : repo.persist_failure(record, 'Publish returned false')
|
115
80
|
ok
|
116
81
|
rescue => e
|
117
|
-
|
118
|
-
begin
|
119
|
-
if record
|
120
|
-
ModelUtils.assign_known_attrs(record, {
|
121
|
-
status: 'failed',
|
122
|
-
last_error: "#{e.class}: #{e.message}",
|
123
|
-
updated_at: (record.respond_to?(:updated_at) ? Time.now.utc : nil)
|
124
|
-
})
|
125
|
-
record.save!
|
126
|
-
end
|
127
|
-
rescue => e2
|
128
|
-
Logging.warn("Failed to persist outbox failure: #{e2.class}: #{e2.message}",
|
129
|
-
tag: 'JetstreamBridge::Publisher')
|
130
|
-
end
|
82
|
+
repo.persist_exception(record, e) if defined?(repo) && defined?(record)
|
131
83
|
log_error(false, e)
|
132
84
|
end
|
133
85
|
# ---- /Outbox path ----
|