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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +46 -5
- data/config/mammoth.example.yml +249 -0
- data/config/mammoth.schema.json +186 -40
- data/lib/mammoth/application.rb +87 -8
- data/lib/mammoth/cli.rb +23 -2
- data/lib/mammoth/concurrent_delivery_runtime.rb +52 -0
- data/lib/mammoth/dead_letter_commands.rb +217 -0
- data/lib/mammoth/dead_letter_store.rb +28 -7
- data/lib/mammoth/delivered_envelope_store.rb +102 -0
- data/lib/mammoth/delivery_processor.rb +72 -0
- data/lib/mammoth/delivery_worker.rb +74 -12
- data/lib/mammoth/replication_consumer.rb +39 -4
- data/lib/mammoth/sources/postgres.rb +189 -9
- data/lib/mammoth/sql/__bootstrap__.sql +24 -0
- data/lib/mammoth/transaction_envelope_serializer.rb +91 -0
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth/webhook_sink.rb +94 -16
- data/lib/mammoth.rb +5 -0
- metadata +20 -1
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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, :
|
|
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.
|
|
71
|
-
|
|
72
|
-
|
|
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(
|
|
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
|
-
|
|
122
|
+
def checkpoint(work, serializer:)
|
|
123
|
+
checkpoint_payload(serializer.call(work))
|
|
124
|
+
end
|
|
82
125
|
|
|
83
|
-
def
|
|
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
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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
|
-
|
|
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
|