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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19aba46c6ef2ae1099287e37281015b747dde1b32c94cbd1c8ec8a23d6b1a19b
4
- data.tar.gz: 21430175d42c71e83eaede3f70390a87d760a9ac2d8b9b4dedcfb446feefaaa0
3
+ metadata.gz: 5d7d570675d2b6731d783c6eba0f347ab00c1d7f9e524c20eef4908af129c41a
4
+ data.tar.gz: f14c3223b20cd1ea8e7d795e1cd2fe4e512766a0a4ed275515e4d1174bb3d6f5
5
5
  SHA512:
6
- metadata.gz: b29ea1b4ace38ffa6eb155251dbcd0056e400849aa6a37093d4ecec09cc314089c36523b22b3bbbf898b0247fb6918e3320ef0f14b24d280fed91df2a2287f9a
7
- data.tar.gz: b2e6f58f77884641970e838a5aaa8c2c1ef0a2531b868a53c3ae8ee450387128b04b39c2661e5fac507c82d05b11739a913285c8f9c2052db137810881073a3e
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
- 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.
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mammoth
4
+ # Namespace for Mammoth source adapters.
4
5
  module Sources
5
6
  # Concrete PostgreSQL CDC source for Mammoth.
6
7
  #
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
@@ -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.2.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