jetstream_bridge 1.6.0 → 1.7.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 -5
- 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 +98 -0
- data/lib/jetstream_bridge/models/outbox_event.rb +114 -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 +12 -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,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
rescue LoadError
|
6
|
+
# No-op; shim below if AR missing.
|
7
|
+
end
|
8
|
+
|
9
|
+
module JetstreamBridge
|
10
|
+
# Default Inbox model when `use_inbox` is enabled.
|
11
|
+
# Prefers event_id, but can fall back to (stream, stream_seq).
|
12
|
+
if defined?(ActiveRecord::Base)
|
13
|
+
class InboxEvent < ActiveRecord::Base
|
14
|
+
self.table_name = 'jetstream_inbox_events'
|
15
|
+
|
16
|
+
class << self
|
17
|
+
def column?(name) = column_names.include?(name.to_s)
|
18
|
+
|
19
|
+
def attribute_json?(name)
|
20
|
+
return false unless respond_to?(:attribute_types) && attribute_types.key?(name.to_s)
|
21
|
+
attribute_types[name.to_s].to_s.downcase.include?('json')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
if column?(:payload)
|
26
|
+
serialize :payload, coder: JSON unless attribute_json?(:payload)
|
27
|
+
end
|
28
|
+
if column?(:headers)
|
29
|
+
serialize :headers, coder: JSON unless attribute_json?(:headers)
|
30
|
+
end
|
31
|
+
|
32
|
+
if column?(:event_id)
|
33
|
+
validates :event_id, presence: true, uniqueness: true
|
34
|
+
elsif column?(:stream_seq)
|
35
|
+
validates :stream_seq, presence: true
|
36
|
+
validates :stream, presence: true, if: -> { self.class.column?(:stream) }
|
37
|
+
if column?(:stream)
|
38
|
+
validates :stream_seq, uniqueness: { scope: :stream }
|
39
|
+
else
|
40
|
+
validates :stream_seq, uniqueness: true
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
validates :subject, presence: true, if: -> { self.class.column?(:subject) }
|
45
|
+
|
46
|
+
if self.class.column?(:status)
|
47
|
+
STATUSES = %w[received processing processed failed].freeze
|
48
|
+
validates :status, inclusion: { in: STATUSES }
|
49
|
+
end
|
50
|
+
|
51
|
+
scope :processed, -> { where(status: 'processed') }, if: -> { column?(:status) }
|
52
|
+
|
53
|
+
before_validation do
|
54
|
+
self.status ||= 'received' if self.class.column?(:status) && status.blank?
|
55
|
+
self.received_at ||= Time.now.utc if self.class.column?(:received_at) && received_at.blank?
|
56
|
+
end
|
57
|
+
|
58
|
+
def processed?
|
59
|
+
if self.class.column?(:processed_at)
|
60
|
+
processed_at.present?
|
61
|
+
elsif self.class.column?(:status)
|
62
|
+
status == 'processed'
|
63
|
+
else
|
64
|
+
false
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def payload_hash
|
69
|
+
v = self[:payload]
|
70
|
+
case v
|
71
|
+
when String then JSON.parse(v) rescue {}
|
72
|
+
when Hash then v
|
73
|
+
else v.respond_to?(:as_json) ? v.as_json : {}
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
else
|
78
|
+
class InboxEvent
|
79
|
+
class << self
|
80
|
+
def method_missing(method_name, *_args, &_block)
|
81
|
+
raise_missing_ar!('Inbox', method_name)
|
82
|
+
end
|
83
|
+
|
84
|
+
def respond_to_missing?(_name, _priv = false) = false
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def raise_missing_ar!(which, method_name)
|
89
|
+
raise(
|
90
|
+
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
91
|
+
'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
|
92
|
+
'`gem "activerecord"` to your Gemfile.'
|
93
|
+
)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
rescue LoadError
|
6
|
+
# No-op; we provide a shim below if AR is missing.
|
7
|
+
end
|
8
|
+
|
9
|
+
module JetstreamBridge
|
10
|
+
# Default Outbox model when `use_outbox` is enabled.
|
11
|
+
# Works with event-centric columns and stays compatible with legacy resource_* fields.
|
12
|
+
if defined?(ActiveRecord::Base)
|
13
|
+
class OutboxEvent < ActiveRecord::Base
|
14
|
+
self.table_name = 'jetstream_outbox_events'
|
15
|
+
|
16
|
+
class << self
|
17
|
+
def column?(name) = column_names.include?(name.to_s)
|
18
|
+
|
19
|
+
def attribute_json?(name)
|
20
|
+
return false unless respond_to?(:attribute_types) && attribute_types.key?(name.to_s)
|
21
|
+
attribute_types[name.to_s].to_s.downcase.include?('json')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# JSON casting fallback if column is text
|
26
|
+
if column?(:payload)
|
27
|
+
serialize :payload, coder: JSON unless attribute_json?(:payload)
|
28
|
+
end
|
29
|
+
if column?(:headers)
|
30
|
+
serialize :headers, coder: JSON unless attribute_json?(:headers)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Validations (guarded by column existence)
|
34
|
+
validates :payload, presence: true, if: -> { self.class.column?(:payload) }
|
35
|
+
|
36
|
+
if self.class.column?(:event_id)
|
37
|
+
validates :event_id, presence: true, uniqueness: true
|
38
|
+
else
|
39
|
+
validates :resource_type, presence: true, if: -> { self.class.column?(:resource_type) }
|
40
|
+
validates :resource_id, presence: true, if: -> { self.class.column?(:resource_id) }
|
41
|
+
validates :event_type, presence: true, if: -> { self.class.column?(:event_type) }
|
42
|
+
end
|
43
|
+
|
44
|
+
validates :subject, presence: true, if: -> { self.class.column?(:subject) }
|
45
|
+
|
46
|
+
if self.class.column?(:status)
|
47
|
+
STATUSES = %w[pending publishing sent failed].freeze
|
48
|
+
validates :status, inclusion: { in: STATUSES }
|
49
|
+
end
|
50
|
+
|
51
|
+
if self.class.column?(:attempts)
|
52
|
+
validates :attempts, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
53
|
+
end
|
54
|
+
|
55
|
+
# Scopes (optional)
|
56
|
+
scope :pending, -> { where(status: 'pending') }, if: -> { column?(:status) }
|
57
|
+
scope :publishing, -> { where(status: 'publishing') }, if: -> { column?(:status) }
|
58
|
+
scope :failed, -> { where(status: 'failed') }, if: -> { column?(:status) }
|
59
|
+
scope :sent, -> { where(status: 'sent') }, if: -> { column?(:status) }
|
60
|
+
scope :ready_to_send, -> { where(status: %w[pending failed]) }, if: -> { column?(:status) }
|
61
|
+
|
62
|
+
before_validation do
|
63
|
+
now = Time.now.utc
|
64
|
+
self.status ||= 'pending' if self.class.column?(:status) && status.blank?
|
65
|
+
self.enqueued_at ||= now if self.class.column?(:enqueued_at) && enqueued_at.blank?
|
66
|
+
self.attempts = 0 if self.class.column?(:attempts) && attempts.nil?
|
67
|
+
end
|
68
|
+
|
69
|
+
# Helpers (no-ops if columns missing)
|
70
|
+
def mark_sent!
|
71
|
+
now = Time.now.utc
|
72
|
+
self.status = 'sent' if self.class.column?(:status)
|
73
|
+
self.sent_at = now if self.class.column?(:sent_at)
|
74
|
+
save!
|
75
|
+
end
|
76
|
+
|
77
|
+
def mark_failed!(err_msg)
|
78
|
+
self.status = 'failed' if self.class.column?(:status)
|
79
|
+
self.last_error = err_msg if self.class.column?(:last_error)
|
80
|
+
save!
|
81
|
+
end
|
82
|
+
|
83
|
+
def payload_hash
|
84
|
+
v = self[:payload]
|
85
|
+
case v
|
86
|
+
when String then JSON.parse(v) rescue {}
|
87
|
+
when Hash then v
|
88
|
+
else v.respond_to?(:as_json) ? v.as_json : {}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
else
|
93
|
+
# Shim: friendly error if AR is not available.
|
94
|
+
class OutboxEvent
|
95
|
+
class << self
|
96
|
+
def method_missing(method_name, *_args, &_block)
|
97
|
+
raise_missing_ar!('Outbox', method_name)
|
98
|
+
end
|
99
|
+
|
100
|
+
def respond_to_missing?(_name, _priv = false) = false
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def raise_missing_ar!(which, method_name)
|
105
|
+
raise(
|
106
|
+
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
107
|
+
'Enable `use_outbox` only in apps with ActiveRecord, or add ' \
|
108
|
+
'`gem "activerecord"` to your Gemfile.'
|
109
|
+
)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
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
|