mammoth 0.2.0 → 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 +8 -0
- data/README.md +1 -0
- data/lib/mammoth/cli.rb +22 -1
- data/lib/mammoth/dead_letter_commands.rb +217 -0
- data/lib/mammoth/dead_letter_store.rb +25 -4
- data/lib/mammoth/delivered_envelope_store.rb +4 -0
- data/lib/mammoth/delivery_processor.rb +4 -0
- data/lib/mammoth/sources/postgres.rb +1 -0
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth/webhook_sink.rb +2 -0
- data/lib/mammoth.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d7d570675d2b6731d783c6eba0f347ab00c1d7f9e524c20eef4908af129c41a
|
|
4
|
+
data.tar.gz: f14c3223b20cd1ea8e7d795e1cd2fe4e512766a0a4ed275515e4d1174bb3d6f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf649aba9068bdc1c3b0e01c1254f8f16804e80ec88de725d2743e99be9b9010bd880590fde044c9d7dff3b0b99fac54c6c9776af4504fb566eeceb1e5b00fd9
|
|
7
|
+
data.tar.gz: a7396f91f4fe84b3780f9b176931d728707b6e55ecab9b168b5eb3e87cb384c2066d9ed0b944dd14405d2e06a02e0e363a28348325578bb652aa4ba1df56caee
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added dead-letter inspection and replay commands for operational recovery.
|
|
10
|
+
- Added dead-letter support for list, show, and replay, with replay routing both event and transaction dead letters back through the existing delivery path and resolving successful rows
|
|
11
|
+
|
|
12
|
+
|
|
5
13
|
## 0.2.0
|
|
6
14
|
|
|
7
15
|
### Added
|
data/README.md
CHANGED
|
@@ -47,6 +47,7 @@ https://kanutocd.github.io/mammoth/Mammoth.html
|
|
|
47
47
|
- dead letter persistence
|
|
48
48
|
- webhook delivery sink
|
|
49
49
|
- delivery worker with retry, checkpoint, and DLQ handling
|
|
50
|
+
- dead-letter inspection and replay commands
|
|
50
51
|
- CDC-core event serialization boundary
|
|
51
52
|
- CDC Ecosystem source-adapter integration boundary
|
|
52
53
|
- Docker image support
|
data/lib/mammoth/cli.rb
CHANGED
|
@@ -5,6 +5,16 @@ require "json"
|
|
|
5
5
|
module Mammoth
|
|
6
6
|
# Small command dispatcher for Mammoth's operator-facing CLI.
|
|
7
7
|
class CLI
|
|
8
|
+
# Internal replay envelope used for transaction dead-letter recovery.
|
|
9
|
+
DEAD_LETTER_TRANSACTION_ENVELOPE = Data.define(
|
|
10
|
+
:events,
|
|
11
|
+
:transaction_id,
|
|
12
|
+
:source_position,
|
|
13
|
+
:commit_lsn,
|
|
14
|
+
:committed_at,
|
|
15
|
+
:metadata
|
|
16
|
+
)
|
|
17
|
+
|
|
8
18
|
# Human-readable command usage printed for invalid or incomplete invocations.
|
|
9
19
|
USAGE = [
|
|
10
20
|
"Usage:",
|
|
@@ -13,7 +23,10 @@ module Mammoth
|
|
|
13
23
|
" mammoth bootstrap CONFIG",
|
|
14
24
|
" mammoth status CONFIG",
|
|
15
25
|
" mammoth start CONFIG",
|
|
16
|
-
" mammoth deliver-sample CONFIG EVENT_JSON"
|
|
26
|
+
" mammoth deliver-sample CONFIG EVENT_JSON",
|
|
27
|
+
" mammoth dead-letters list CONFIG [--status STATUS] [--limit N]",
|
|
28
|
+
" mammoth dead-letters show CONFIG ID",
|
|
29
|
+
" mammoth dead-letters replay CONFIG [ID ...]"
|
|
17
30
|
].join("\n")
|
|
18
31
|
|
|
19
32
|
# Run the CLI.
|
|
@@ -42,6 +55,7 @@ module Mammoth
|
|
|
42
55
|
when "status" then status
|
|
43
56
|
when "start" then start
|
|
44
57
|
when "deliver-sample" then deliver_sample
|
|
58
|
+
when "dead-letters" then dead_letters
|
|
45
59
|
else
|
|
46
60
|
warn USAGE
|
|
47
61
|
1
|
|
@@ -109,6 +123,13 @@ module Mammoth
|
|
|
109
123
|
raise ConfigurationError, "invalid event JSON in #{event_path}: #{e.message}"
|
|
110
124
|
end
|
|
111
125
|
|
|
126
|
+
# Dispatch the nested dead-letter command group.
|
|
127
|
+
#
|
|
128
|
+
# @return [Integer] process status code
|
|
129
|
+
def dead_letters
|
|
130
|
+
DeadLetterCommands.call(argv)
|
|
131
|
+
end
|
|
132
|
+
|
|
112
133
|
def load_config
|
|
113
134
|
raise ConfigurationError, "configuration path required\n#{USAGE}" unless config_path
|
|
114
135
|
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -9,6 +9,7 @@ module Mammoth
|
|
|
9
9
|
# keeps a small delivery ledger so a transaction replayed by the upstream
|
|
10
10
|
# replication source after restart does not have to be delivered downstream again.
|
|
11
11
|
class DeliveredEnvelopeStore
|
|
12
|
+
# SQLite schema used to bootstrap the delivered-envelope ledger.
|
|
12
13
|
SCHEMA = <<~SQL
|
|
13
14
|
CREATE TABLE IF NOT EXISTS delivered_envelopes (
|
|
14
15
|
id INTEGER PRIMARY KEY,
|
|
@@ -81,6 +82,9 @@ module Mammoth
|
|
|
81
82
|
database.execute("SELECT * FROM delivered_envelopes ORDER BY id")
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
# Count delivered envelopes.
|
|
86
|
+
#
|
|
87
|
+
# @return [Integer] delivered envelope count
|
|
84
88
|
def count
|
|
85
89
|
database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
|
|
86
90
|
end
|
|
@@ -56,6 +56,10 @@ module Mammoth
|
|
|
56
56
|
process(work)
|
|
57
57
|
end
|
|
58
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
|
|
59
63
|
def process(work)
|
|
60
64
|
case delivery_unit
|
|
61
65
|
when :transaction
|
data/lib/mammoth/version.rb
CHANGED
data/lib/mammoth/webhook_sink.rb
CHANGED
|
@@ -13,7 +13,9 @@ module Mammoth
|
|
|
13
13
|
# HTTP status range treated as successful webhook delivery.
|
|
14
14
|
SUCCESS_RANGE = 200..299
|
|
15
15
|
|
|
16
|
+
# Supported webhook signing algorithm.
|
|
16
17
|
SIGNING_ALGORITHM = "hmac_sha256"
|
|
18
|
+
# Prefix added to generated webhook signatures.
|
|
17
19
|
SIGNATURE_PREFIX = "sha256="
|
|
18
20
|
|
|
19
21
|
attr_reader :name, :url, :timeout_seconds, :headers, :signing
|
data/lib/mammoth.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative "mammoth/sources/postgres"
|
|
|
18
18
|
require_relative "mammoth/cdc_source"
|
|
19
19
|
require_relative "mammoth/replication_consumer"
|
|
20
20
|
require_relative "mammoth/application"
|
|
21
|
+
require_relative "mammoth/dead_letter_commands"
|
|
21
22
|
require_relative "mammoth/cli"
|
|
22
23
|
|
|
23
24
|
# Mammoth is a self-hosted PostgreSQL event relay.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mammoth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -150,6 +150,7 @@ files:
|
|
|
150
150
|
- lib/mammoth/cli.rb
|
|
151
151
|
- lib/mammoth/concurrent_delivery_runtime.rb
|
|
152
152
|
- lib/mammoth/configuration.rb
|
|
153
|
+
- lib/mammoth/dead_letter_commands.rb
|
|
153
154
|
- lib/mammoth/dead_letter_store.rb
|
|
154
155
|
- lib/mammoth/delivered_envelope_store.rb
|
|
155
156
|
- lib/mammoth/delivery_processor.rb
|