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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.idea/dictionaries/project.xml +1 -0
  3. data/.idea/jetstream_bridge.iml +6 -1
  4. data/.rubocop.yml +102 -0
  5. data/Gemfile.lock +1 -5
  6. data/README.md +76 -32
  7. data/jetstream_bridge.gemspec +9 -10
  8. data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +16 -0
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +24 -0
  10. data/lib/generators/jetstream_bridge/install/install_generator.rb +19 -0
  11. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +44 -0
  12. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +24 -0
  13. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +21 -0
  14. data/lib/jetstream_bridge/consumer/consumer.rb +103 -0
  15. data/lib/jetstream_bridge/{consumer_config.rb → consumer/consumer_config.rb} +3 -3
  16. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +50 -0
  17. data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +51 -0
  18. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +102 -0
  19. data/lib/jetstream_bridge/{message_processor.rb → consumer/message_processor.rb} +1 -1
  20. data/lib/jetstream_bridge/consumer/subscription_manager.rb +91 -0
  21. data/lib/jetstream_bridge/{connection.rb → core/connection.rb} +1 -1
  22. data/lib/jetstream_bridge/models/inbox_event.rb +98 -0
  23. data/lib/jetstream_bridge/models/outbox_event.rb +114 -0
  24. data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
  25. data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +10 -58
  26. data/lib/jetstream_bridge/railtie.rb +12 -0
  27. data/lib/jetstream_bridge/tasks/install.rake +10 -0
  28. data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
  29. data/lib/jetstream_bridge/topology/stream.rb +129 -0
  30. data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
  31. data/lib/jetstream_bridge/version.rb +1 -1
  32. data/lib/jetstream_bridge.rb +35 -23
  33. metadata +49 -50
  34. data/lib/jetstream_bridge/consumer.rb +0 -232
  35. data/lib/jetstream_bridge/dlq.rb +0 -24
  36. data/lib/jetstream_bridge/inbox_event.rb +0 -46
  37. data/lib/jetstream_bridge/outbox_event.rb +0 -60
  38. data/lib/jetstream_bridge/stream.rb +0 -114
  39. /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
  40. /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
  41. /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
  42. /data/lib/jetstream_bridge/{model_utils.rb → core/model_utils.rb} +0 -0
  43. /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 'logging'
5
- require_relative 'config'
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'securerandom'
5
- require_relative 'logging'
5
+ require_relative '../core/logging'
6
6
 
7
7
  module JetstreamBridge
8
8
  # Handles parse → handler → ack / nak → DLQ
@@ -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
@@ -5,8 +5,8 @@ require 'singleton'
5
5
  require 'json'
6
6
  require_relative 'duration'
7
7
  require_relative 'logging'
8
- require_relative 'topology'
9
8
  require_relative 'config'
9
+ require_relative '../topology/topology'
10
10
 
11
11
  module JetstreamBridge
12
12
  # Singleton connection to NATS.
@@ -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