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.
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 -7
  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 +101 -0
  23. data/lib/jetstream_bridge/models/outbox_event.rb +100 -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 +37 -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,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
- now = Time.now.utc
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
- record = ModelUtils.find_or_init_by_best(
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
- # populate / update
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
- # Persist the failure on the outbox row as best as we can
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 ----