mammoth 0.1.1 → 0.3.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.
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mammoth
6
+ # Operator commands for inspecting and replaying dead letters.
7
+ # rubocop:disable Metrics/ClassLength
8
+ class DeadLetterCommands
9
+ # Internal replay envelope used for transaction dead-letter recovery.
10
+ DEAD_LETTER_TRANSACTION_ENVELOPE = Data.define(
11
+ :events,
12
+ :transaction_id,
13
+ :source_position,
14
+ :commit_lsn,
15
+ :committed_at,
16
+ :metadata
17
+ )
18
+
19
+ attr_reader :argv
20
+
21
+ # @param argv [Array<String>] command line arguments
22
+ def self.call(argv)
23
+ new(argv).call
24
+ end
25
+
26
+ # @param argv [Array<String>] command line arguments
27
+ def initialize(argv)
28
+ @argv = argv
29
+ end
30
+
31
+ # Dispatch the nested dead-letter subcommand.
32
+ #
33
+ # @return [Integer] process status code
34
+ def call
35
+ case command
36
+ when "list" then list
37
+ when "show" then show
38
+ when "replay" then replay
39
+ else
40
+ raise ConfigurationError, "dead-letters subcommand required\n#{CLI::USAGE}"
41
+ end
42
+ rescue Mammoth::Error => e
43
+ warn e.message
44
+ 1
45
+ end
46
+
47
+ private
48
+
49
+ def command
50
+ argv.fetch(1, nil)
51
+ end
52
+
53
+ def config_path
54
+ argv.fetch(2, nil)
55
+ end
56
+
57
+ def list
58
+ rows = dead_letter_store.rows(status: list_options.fetch(:status), limit: list_options.fetch(:limit))
59
+ puts list_header
60
+ rows.each { |row| puts list_row(row) }
61
+ 0
62
+ end
63
+
64
+ def show
65
+ row = fetch_dead_letter!(dead_letter_id)
66
+ puts JSON.pretty_generate(show_payload(row))
67
+ 0
68
+ end
69
+
70
+ def replay
71
+ rows = replay_rows
72
+ raise ConfigurationError, "no dead letters found to replay" if rows.empty?
73
+
74
+ rows.each do |row|
75
+ result = replay_row(row)
76
+ dead_letter_store.resolve(row.fetch("id")) unless result.fetch(:status) == "dead_lettered"
77
+ puts replay_message(row, result)
78
+ end
79
+ 0
80
+ end
81
+
82
+ def load_config
83
+ raise ConfigurationError, "configuration path required\n#{CLI::USAGE}" unless config_path
84
+
85
+ Configuration.load(config_path)
86
+ end
87
+
88
+ def dead_letter_store
89
+ @dead_letter_store ||= DeadLetterStore.new(SQLiteStore.connect(load_config.dig("sqlite", "path")).bootstrap!)
90
+ end
91
+
92
+ def worker
93
+ @worker ||= Application.new(load_config).delivery_worker
94
+ end
95
+
96
+ def dead_letter_id
97
+ raw_id = argv.fetch(3, nil)
98
+ raise ConfigurationError, "dead letter id required\n#{CLI::USAGE}" unless raw_id
99
+
100
+ Integer(raw_id)
101
+ rescue ArgumentError
102
+ raise ConfigurationError, "dead letter id must be an integer"
103
+ end
104
+
105
+ # Parse dead-letter list filters and pagination.
106
+ #
107
+ # @return [Hash] list options
108
+ def list_options
109
+ options = { status: "pending", limit: 100 }
110
+ index = 0
111
+ args = argv.drop(3)
112
+
113
+ # rubocop:disable Style/WhileUntilModifier
114
+ while index < args.length
115
+ index = parse_list_option(args, index, options)
116
+ end
117
+ # rubocop:enable Style/WhileUntilModifier
118
+
119
+ options
120
+ end
121
+
122
+ def parse_list_option(args, index, options)
123
+ case args.fetch(index)
124
+ when "--status"
125
+ options[:status] = args.fetch(index + 1)
126
+ index + 2
127
+ when "--limit"
128
+ options[:limit] = Integer(args.fetch(index + 1))
129
+ index + 2
130
+ else
131
+ raise ConfigurationError, "unknown option #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
132
+
133
+ raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}"
134
+ end
135
+ rescue ArgumentError, IndexError
136
+ raise ConfigurationError, "dead letter limit must be an integer"
137
+ end
138
+
139
+ # Resolve the rows that should be replayed.
140
+ #
141
+ # @return [Array<Hash>] replay rows
142
+ def replay_rows
143
+ ids = argv.drop(3)
144
+ return dead_letter_store.pending if ids.empty?
145
+
146
+ ids.map do |raw_id|
147
+ id = Integer(raw_id)
148
+ row = dead_letter_store.fetch(id)
149
+ raise ConfigurationError, "dead letter not found: #{id}" unless row
150
+
151
+ row
152
+ rescue ArgumentError
153
+ raise ConfigurationError, "dead letter id must be an integer"
154
+ end
155
+ end
156
+
157
+ def fetch_dead_letter!(id)
158
+ row = dead_letter_store.fetch(id)
159
+ raise ConfigurationError, "dead letter not found: #{id}" unless row
160
+
161
+ row
162
+ end
163
+
164
+ def replay_row(row)
165
+ payload = JSON.parse(row.fetch("payload_json"))
166
+ transaction_payload?(payload) ? worker.deliver_transaction(transaction_envelope(payload)) : worker.deliver(payload)
167
+ end
168
+
169
+ def transaction_payload?(payload)
170
+ payload.fetch("type", nil) == TransactionEnvelopeSerializer::PAYLOAD_TYPE
171
+ end
172
+
173
+ def transaction_envelope(payload)
174
+ DEAD_LETTER_TRANSACTION_ENVELOPE.new(
175
+ payload.fetch("events"),
176
+ payload.fetch("transaction_id"),
177
+ payload["source_position"],
178
+ payload["commit_lsn"],
179
+ payload["committed_at"],
180
+ payload["metadata"] || {}
181
+ )
182
+ end
183
+
184
+ def show_payload(row)
185
+ row.merge("payload" => JSON.parse(row.fetch("payload_json")))
186
+ end
187
+
188
+ def list_header
189
+ format(
190
+ "%<id>-4s %<status>-10s %<destination>-18s %<retries>-8s %<failed_at>-19s %<event>s",
191
+ id: "ID",
192
+ status: "STATUS",
193
+ destination: "DESTINATION",
194
+ retries: "RETRIES",
195
+ failed_at: "FAILED_AT",
196
+ event: "EVENT"
197
+ )
198
+ end
199
+
200
+ def list_row(row)
201
+ format(
202
+ "%<id>-4s %<status>-10s %<destination>-18s %<retries>-8s %<failed_at>-19s %<event>s",
203
+ id: row.fetch("id"),
204
+ status: row.fetch("status"),
205
+ destination: row.fetch("destination_name"),
206
+ retries: row.fetch("retry_count"),
207
+ failed_at: row.fetch("failed_at"),
208
+ event: row.fetch("event_id")
209
+ )
210
+ end
211
+
212
+ def replay_message(row, result)
213
+ "Dead letter #{row.fetch("id")}: #{result.fetch(:status)}"
214
+ end
215
+ end
216
+ # rubocop:enable Metrics/ClassLength
217
+ end
@@ -20,9 +20,9 @@ module Mammoth
20
20
  # @param error [Exception, nil] delivery failure
21
21
  # @param retry_count [Integer] number of delivery attempts
22
22
  # @return [Integer] inserted dead letter id
23
- def write(event:, destination_name:, error: nil, retry_count: 0) # rubocop:disable Metrics/MethodLength
23
+ def write(event:, destination_name:, error: nil, retry_count: 0, serializer: EventSerializer) # rubocop:disable Metrics/MethodLength
24
24
  now = Time.now.utc.iso8601
25
- payload = EventSerializer.call(event)
25
+ payload = serializer.call(event)
26
26
  database.execute(
27
27
  <<~SQL,
28
28
  INSERT INTO dead_letters(
@@ -46,7 +46,7 @@ module Mammoth
46
46
  payload.fetch("event_id"),
47
47
  payload.fetch("source"),
48
48
  destination_name,
49
- payload.fetch("operation"),
49
+ payload["operation"] || payload["type"],
50
50
  payload["namespace"],
51
51
  payload["entity"],
52
52
  payload["source_position"],
@@ -66,10 +66,31 @@ module Mammoth
66
66
  # @param limit [Integer] maximum number of rows
67
67
  # @return [Array<Hash>] pending dead letter rows
68
68
  def pending(limit: 100)
69
- database.execute(
70
- "SELECT * FROM dead_letters WHERE status = ? ORDER BY failed_at ASC LIMIT ?",
71
- ["pending", limit]
72
- )
69
+ rows(status: "pending", limit: limit)
70
+ end
71
+
72
+ # Fetch dead letters, optionally filtered by status.
73
+ #
74
+ # @param status [String, nil] optional dead-letter status filter
75
+ # @param limit [Integer] maximum number of rows
76
+ # @return [Array<Hash>] dead letter rows
77
+ def rows(status: nil, limit: 100)
78
+ if status && status != "all"
79
+ return database.execute(
80
+ "SELECT * FROM dead_letters WHERE status = ? ORDER BY failed_at ASC LIMIT ?",
81
+ [status, limit]
82
+ )
83
+ end
84
+
85
+ database.execute("SELECT * FROM dead_letters ORDER BY failed_at ASC LIMIT ?", [limit])
86
+ end
87
+
88
+ # Fetch one dead letter by id.
89
+ #
90
+ # @param id [Integer] dead letter id
91
+ # @return [Hash, nil] dead letter row
92
+ def fetch(id)
93
+ database.get_first_row("SELECT * FROM dead_letters WHERE id = ?", [id])
73
94
  end
74
95
 
75
96
  # Count dead letters by status.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Mammoth
6
+ # SQLite-backed ledger of downstream deliveries.
7
+ #
8
+ # The PostgreSQL replication boundary is at-least-once. Mammoth therefore
9
+ # keeps a small delivery ledger so a transaction replayed by the upstream
10
+ # replication source after restart does not have to be delivered downstream again.
11
+ class DeliveredEnvelopeStore
12
+ # SQLite schema used to bootstrap the delivered-envelope ledger.
13
+ SCHEMA = <<~SQL
14
+ CREATE TABLE IF NOT EXISTS delivered_envelopes (
15
+ id INTEGER PRIMARY KEY,
16
+ idempotency_key TEXT NOT NULL,
17
+ source_name TEXT NOT NULL,
18
+ slot_name TEXT NOT NULL,
19
+ destination_name TEXT NOT NULL,
20
+ delivery_unit TEXT NOT NULL,
21
+ transaction_id TEXT,
22
+ source_position TEXT,
23
+ delivered_at TEXT NOT NULL,
24
+
25
+ UNIQUE (idempotency_key)
26
+ );
27
+
28
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source
29
+ ON delivered_envelopes(source_name, slot_name);
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_destination
32
+ ON delivered_envelopes(destination_name);
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source_position
35
+ ON delivered_envelopes(source_position);
36
+ SQL
37
+
38
+ attr_reader :sqlite_store
39
+
40
+ def initialize(sqlite_store)
41
+ @sqlite_store = sqlite_store
42
+ ensure_schema!
43
+ end
44
+
45
+ def delivered?(idempotency_key)
46
+ !database.execute(
47
+ "SELECT 1 FROM delivered_envelopes WHERE idempotency_key = ? LIMIT 1",
48
+ [idempotency_key]
49
+ ).empty?
50
+ end
51
+
52
+ # rubocop:disable Metrics/MethodLength
53
+ def record!(idempotency_key:, source_name:, slot_name:, destination_name:, delivery_unit:, transaction_id:,
54
+ source_position:)
55
+ database.execute(
56
+ <<~SQL,
57
+ INSERT OR IGNORE INTO delivered_envelopes(
58
+ idempotency_key, source_name, slot_name, destination_name, delivery_unit,
59
+ transaction_id, source_position, delivered_at
60
+ )
61
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
62
+ SQL
63
+ [
64
+ idempotency_key,
65
+ source_name,
66
+ slot_name,
67
+ destination_name,
68
+ delivery_unit,
69
+ transaction_id,
70
+ source_position,
71
+ Time.now.utc.iso8601
72
+ ]
73
+ )
74
+ database.get_first_row(
75
+ "SELECT * FROM delivered_envelopes WHERE idempotency_key = ? LIMIT 1",
76
+ [idempotency_key]
77
+ )
78
+ end
79
+ # rubocop:enable Metrics/MethodLength
80
+
81
+ def all
82
+ database.execute("SELECT * FROM delivered_envelopes ORDER BY id")
83
+ end
84
+
85
+ # Count delivered envelopes.
86
+ #
87
+ # @return [Integer] delivered envelope count
88
+ def count
89
+ database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
90
+ end
91
+
92
+ private
93
+
94
+ def ensure_schema!
95
+ database.execute_batch(SCHEMA)
96
+ end
97
+
98
+ def database
99
+ sqlite_store.bootstrap!.database
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Adapter object used by CDC::Concurrent::ProcessorPool.
5
+ #
6
+ # The processor keeps cdc-concurrent integration narrow: cdc-concurrent owns
7
+ # I/O-heavy fan-out mechanics, while DeliveryWorker owns Mammoth relay
8
+ # semantics such as retries, dead letters, and checkpoint writes.
9
+ class DeliveryProcessor
10
+ @concurrent_safe = false
11
+
12
+ class << self
13
+ # Mark this processor as safe for CDC::Concurrent::ProcessorPool.
14
+ #
15
+ # DeliveryProcessor itself is intentionally stateless after initialization;
16
+ # per-delivery retry, dead-letter, and checkpoint behavior remains owned by
17
+ # the injected DeliveryWorker.
18
+ #
19
+ # @return [true]
20
+ def concurrent_safe!
21
+ @concurrent_safe = true
22
+ end
23
+
24
+ # @return [Boolean] true when this processor has explicitly opted in to
25
+ # cdc-concurrent execution.
26
+ def concurrent_safe?
27
+ @concurrent_safe == true
28
+ end
29
+
30
+ alias concurrent_safe concurrent_safe?
31
+ end
32
+
33
+ concurrent_safe!
34
+
35
+ attr_reader :delivery_worker, :delivery_unit
36
+
37
+ # @param delivery_worker [Mammoth::DeliveryWorker] relay-aware delivery worker
38
+ # @param delivery_unit [String, Symbol] event or transaction
39
+ def initialize(delivery_worker:, delivery_unit: :event)
40
+ @delivery_worker = delivery_worker
41
+ @delivery_unit = delivery_unit.to_sym
42
+ end
43
+
44
+ # @return [Boolean] true when this processor instance is safe for concurrent execution.
45
+ def concurrent_safe?
46
+ self.class.concurrent_safe?
47
+ end
48
+
49
+ alias concurrent_safe concurrent_safe?
50
+
51
+ # Process one work item from CDC::Concurrent::ProcessorPool.
52
+ #
53
+ # @param work [Object] event or transaction envelope
54
+ # @return [Hash] delivery summary
55
+ def call(work)
56
+ process(work)
57
+ end
58
+
59
+ # Process one work item using the configured delivery unit.
60
+ #
61
+ # @param work [Object] event or transaction envelope
62
+ # @return [Hash] delivery summary
63
+ def process(work)
64
+ case delivery_unit
65
+ when :transaction
66
+ delivery_worker.deliver_transaction(work)
67
+ else
68
+ delivery_worker.deliver(work)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -11,12 +11,13 @@ module Mammoth
11
11
  # Default source name used when an event does not provide one.
12
12
  DEFAULT_SOURCE = "postgresql"
13
13
 
14
- attr_reader :sink, :checkpoint_store, :dead_letter_store, :retry_schedule, :max_attempts, :sleeper, :source_name,
15
- :slot_name, :publication_name
14
+ attr_reader :sink, :checkpoint_store, :dead_letter_store, :delivered_envelope_store, :retry_schedule, :max_attempts,
15
+ :sleeper, :source_name, :slot_name, :publication_name
16
16
 
17
17
  # @param sink [#deliver] destination sink
18
18
  # @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
19
19
  # @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
20
+ # @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore, nil] downstream delivery ledger
20
21
  # @param source_name [String] logical source name
21
22
  # @param slot_name [String] replication slot name
22
23
  # @param publication_name [String] publication name
@@ -24,10 +25,11 @@ module Mammoth
24
25
  # @param retry_schedule [Array<Integer>] retry wait schedule in seconds
25
26
  # @param sleeper [#call] sleep strategy, injectable for tests
26
27
  def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:, publication_name:,
27
- max_attempts:, retry_schedule:, sleeper: Kernel.method(:sleep))
28
+ max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep))
28
29
  @sink = sink
29
30
  @checkpoint_store = checkpoint_store
30
31
  @dead_letter_store = dead_letter_store
32
+ @delivered_envelope_store = delivered_envelope_store || DeliveredEnvelopeStore.new(checkpoint_store.sqlite_store)
31
33
  @source_name = source_name
32
34
  @slot_name = slot_name
33
35
  @publication_name = publication_name
@@ -58,30 +60,70 @@ module Mammoth
58
60
  )
59
61
  end
60
62
 
63
+ # Deliver a transaction envelope with retry, checkpoint, and DLQ handling.
64
+ #
65
+ # @param envelope [#events, #transaction_id] CDC transaction envelope
66
+ # @return [Hash] delivery summary
67
+ def deliver_transaction(envelope)
68
+ deliver_work(envelope, serializer: TransactionEnvelopeSerializer, delivery_method: :deliver_transaction)
69
+ end
70
+
61
71
  # Deliver an event with retry, checkpoint, and DLQ handling.
62
72
  #
63
73
  # @param event [Hash, #to_h] normalized event
64
74
  # @return [Hash] delivery summary
65
75
  def deliver(event)
76
+ deliver_work(event, serializer: EventSerializer, delivery_method: :deliver)
77
+ end
78
+
79
+ private
80
+
81
+ # rubocop:disable Metrics/MethodLength
82
+ def deliver_work(work, serializer:, delivery_method:)
66
83
  attempts = 0
84
+ payload = serializer.call(work)
85
+ delivery_unit = delivery_unit_for(delivery_method)
86
+ idempotency_key = idempotency_key_for(payload:, delivery_unit:)
87
+
88
+ if delivered_envelope_store.delivered?(idempotency_key)
89
+ checkpoint_payload(payload)
90
+ return {
91
+ status: "skipped",
92
+ duplicate: true,
93
+ idempotency_key: idempotency_key,
94
+ attempts: attempts,
95
+ destination: destination_name
96
+ }
97
+ end
67
98
 
68
99
  begin
69
100
  attempts += 1
70
- result = sink.deliver(event)
71
- checkpoint(event)
72
- result.merge(attempts: attempts)
101
+ result = sink.public_send(delivery_method, work)
102
+ delivered_envelope_store.record!(
103
+ idempotency_key: idempotency_key,
104
+ source_name: source_name,
105
+ slot_name: slot_name,
106
+ destination_name: destination_name,
107
+ delivery_unit: delivery_unit.to_s,
108
+ transaction_id: payload["transaction_id"],
109
+ source_position: payload["source_position"]
110
+ )
111
+ checkpoint_payload(payload)
112
+ result.merge(attempts: attempts, idempotency_key: idempotency_key)
73
113
  rescue DeliveryError => e
74
- return dead_letter(event, e, attempts) if attempts >= max_attempts
114
+ return dead_letter(work, e, attempts, serializer:) if attempts >= max_attempts
75
115
 
76
116
  wait_before_retry(attempts)
77
117
  retry
78
118
  end
79
119
  end
120
+ # rubocop:enable Metrics/MethodLength
80
121
 
81
- private
122
+ def checkpoint(work, serializer:)
123
+ checkpoint_payload(serializer.call(work))
124
+ end
82
125
 
83
- def checkpoint(event)
84
- payload = EventSerializer.call(event)
126
+ def checkpoint_payload(payload)
85
127
  checkpoint_store.write(
86
128
  source_name: source_name,
87
129
  slot_name: slot_name,
@@ -90,12 +132,32 @@ module Mammoth
90
132
  )
91
133
  end
92
134
 
93
- def dead_letter(event, error, attempts)
135
+ def idempotency_key_for(payload:, delivery_unit:)
136
+ [
137
+ source_name,
138
+ slot_name,
139
+ destination_name,
140
+ delivery_unit,
141
+ payload["transaction_id"] || payload["event_id"],
142
+ payload["source_position"]
143
+ ].compact.join(":")
144
+ end
145
+
146
+ def delivery_unit_for(delivery_method)
147
+ delivery_method == :deliver_transaction ? :transaction : :event
148
+ end
149
+
150
+ def destination_name
151
+ sink.respond_to?(:name) ? sink.name : sink.class.name
152
+ end
153
+
154
+ def dead_letter(event, error, attempts, serializer:)
94
155
  id = dead_letter_store.write(
95
156
  event: event,
96
157
  destination_name: sink.name,
97
158
  error: error,
98
- retry_count: attempts
159
+ retry_count: attempts,
160
+ serializer: serializer
99
161
  )
100
162
  { status: "dead_lettered", dead_letter_id: id, attempts: attempts }
101
163
  end
@@ -5,14 +5,17 @@ module Mammoth
5
5
  #
6
6
  # ReplicationConsumer is intentionally upstream-agnostic. It does not know
7
7
  # which upstream system produced the work. Its
8
- # only job is to consume CDC Ecosystem work, flatten CDC transaction
9
- # envelopes, and yield individual change events to the delivery pipeline.
8
+ # job is to consume CDC Ecosystem work and yield either individual change
9
+ # events or transaction envelopes depending on Mammoth's configured delivery
10
+ # unit.
10
11
  class ReplicationConsumer
11
- attr_reader :source
12
+ attr_reader :source, :delivery_unit
12
13
 
13
14
  # @param source [#each, nil] injectable CDC work stream
14
- def initialize(source: nil)
15
+ # @param delivery_unit [String, Symbol] :event or :transaction
16
+ def initialize(source: nil, delivery_unit: :event)
15
17
  @source = source
18
+ @delivery_unit = delivery_unit.to_sym
16
19
  end
17
20
 
18
21
  # Consume normalized CDC work from the configured source.
@@ -46,16 +49,45 @@ module Mammoth
46
49
 
47
50
  def flatten_cdc_work(work)
48
51
  return [] if work.nil?
52
+ return validate_transaction_envelope(work) if transaction_envelope?(work) && transaction_delivery?
49
53
  return validate_events(work.events) if transaction_envelope?(work)
50
54
  return work.flat_map { |item| flatten_cdc_work(item) } if work.is_a?(Array)
55
+ return [synthetic_transaction_envelope(work)] if transaction_delivery?
51
56
 
52
57
  validate_events([work])
53
58
  end
54
59
 
60
+ def transaction_delivery?
61
+ delivery_unit == :transaction
62
+ end
63
+
64
+ def validate_transaction_envelope(envelope)
65
+ validate_events(envelope.events)
66
+ [envelope]
67
+ end
68
+
55
69
  def validate_events(events)
56
70
  events.each { |event| validate_cdc_event!(event) }
57
71
  end
58
72
 
73
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
74
+ def synthetic_transaction_envelope(event)
75
+ validate_cdc_event!(event)
76
+
77
+ event_hash = event.to_h
78
+ SyntheticTransactionEnvelope.new(
79
+ [event],
80
+ event_hash["transaction_id"] || event_hash[:transaction_id] ||
81
+ event_hash["xid"] || event_hash[:xid] || event_hash["event_id"] || event_hash[:event_id],
82
+ event_hash["commit_lsn"] || event_hash[:commit_lsn] ||
83
+ event_hash["source_position"] || event_hash[:source_position],
84
+ event_hash["committed_at"] || event_hash[:committed_at] ||
85
+ event_hash["occurred_at"] || event_hash[:occurred_at],
86
+ event_hash["metadata"] || event_hash[:metadata] || {}
87
+ )
88
+ end
89
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
90
+
59
91
  def validate_cdc_event!(event)
60
92
  return event if event.respond_to?(:to_h) && cdc_event_hash?(event.to_h)
61
93
 
@@ -74,5 +106,8 @@ module Mammoth
74
106
  def transaction_envelope?(work)
75
107
  work.respond_to?(:events) && work.respond_to?(:transaction_id)
76
108
  end
109
+
110
+ SyntheticTransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
111
+ private_constant :SyntheticTransactionEnvelope
77
112
  end
78
113
  end