mammoth 0.2.0 → 0.4.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 +14 -0
- data/README.md +3 -0
- data/config/mammoth.example.yml +7 -0
- data/config/mammoth.schema.json +18 -0
- data/lib/mammoth/cli.rb +32 -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/observability_server.rb +85 -0
- data/lib/mammoth/observability_snapshot.rb +129 -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 +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fdbb7afb90f584e11024c75d7300f54978f5e5b1a3628597bb460f96a306c22
|
|
4
|
+
data.tar.gz: 9ced2250d7e2f95558a8bcce51809aa4d670d49953d8d970e168a0c48cf90215
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd6b5b3246cf70adb9beb0cc9243e6a35788d20f3442ab406bca66a4cb08bd4a7d80639386c4e42291b518190a0198f960ed9e16cbd165f335a7f3fee10317b7
|
|
7
|
+
data.tar.gz: 87aa4ee77c38aa69d1a505bf7eb2b838de86504316840dfc26b4e875870333f64da906c85aa180a205e5e3bff4f439e36d2f82ffa79d650117d9f5ccc4c29b69
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added optional observability support with `/healthz`, `/readyz`, and `/metrics` endpoints, plus CLI startup, configuration, and documentation coverage.
|
|
10
|
+
|
|
11
|
+
## 0.4.0
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added dead-letter inspection and replay commands for operational recovery.
|
|
16
|
+
- 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
|
|
17
|
+
|
|
18
|
+
|
|
5
19
|
## 0.2.0
|
|
6
20
|
|
|
7
21
|
### Added
|
data/README.md
CHANGED
|
@@ -47,11 +47,13 @@ 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
|
|
53
54
|
- public Helm chart support
|
|
54
55
|
- unit and e2e test tasks
|
|
56
|
+
- Health and metrics endpoints
|
|
55
57
|
|
|
56
58
|
## Boundary
|
|
57
59
|
|
|
@@ -83,6 +85,7 @@ bundle exec ./exe/mammoth validate config/mammoth.example.yml
|
|
|
83
85
|
bundle exec ./exe/mammoth bootstrap config/mammoth.example.yml
|
|
84
86
|
bundle exec ./exe/mammoth status config/mammoth.example.yml
|
|
85
87
|
bundle exec ./exe/mammoth start config/mammoth.example.yml
|
|
88
|
+
bundle exec ./exe/mammoth observability config/mammoth.example.yml
|
|
86
89
|
```
|
|
87
90
|
|
|
88
91
|
Deliver a single normalized event JSON file through Mammoth's delivery path:
|
data/config/mammoth.example.yml
CHANGED
|
@@ -274,6 +274,13 @@ sqlite:
|
|
|
274
274
|
# In Kubernetes, back this path with a PersistentVolumeClaim.
|
|
275
275
|
path: data/mammoth.db
|
|
276
276
|
|
|
277
|
+
observability:
|
|
278
|
+
# Bind host for the optional health, readiness, and metrics server.
|
|
279
|
+
host: 0.0.0.0
|
|
280
|
+
|
|
281
|
+
# HTTP port for /healthz, /readyz, and /metrics.
|
|
282
|
+
port: 9393
|
|
283
|
+
|
|
277
284
|
logging:
|
|
278
285
|
# Log verbosity.
|
|
279
286
|
#
|
data/config/mammoth.schema.json
CHANGED
|
@@ -319,6 +319,24 @@
|
|
|
319
319
|
}
|
|
320
320
|
},
|
|
321
321
|
"description": "Downstream delivery runtime settings. Runtime concurrency affects webhook/destination delivery only, not PostgreSQL replication stream count."
|
|
322
|
+
},
|
|
323
|
+
"observability": {
|
|
324
|
+
"type": "object",
|
|
325
|
+
"additionalProperties": false,
|
|
326
|
+
"properties": {
|
|
327
|
+
"host": {
|
|
328
|
+
"type": "string",
|
|
329
|
+
"minLength": 1,
|
|
330
|
+
"description": "Bind host for the optional Mammoth observability HTTP server exposing /healthz, /readyz, and /metrics."
|
|
331
|
+
},
|
|
332
|
+
"port": {
|
|
333
|
+
"type": "integer",
|
|
334
|
+
"minimum": 1,
|
|
335
|
+
"maximum": 65535,
|
|
336
|
+
"description": "TCP port for the optional Mammoth observability HTTP server."
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
"description": "Optional health, readiness, and metrics endpoint settings."
|
|
322
340
|
}
|
|
323
341
|
},
|
|
324
342
|
"description": "Mammoth relay configuration. Mammoth consumes PostgreSQL logical replication changes and delivers them to downstream destinations while preserving checkpoint, retry, and dead-letter semantics."
|
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,11 @@ 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 ...]",
|
|
30
|
+
" mammoth observability CONFIG"
|
|
17
31
|
].join("\n")
|
|
18
32
|
|
|
19
33
|
# Run the CLI.
|
|
@@ -42,6 +56,8 @@ module Mammoth
|
|
|
42
56
|
when "status" then status
|
|
43
57
|
when "start" then start
|
|
44
58
|
when "deliver-sample" then deliver_sample
|
|
59
|
+
when "dead-letters" then dead_letters
|
|
60
|
+
when "observability" then observability
|
|
45
61
|
else
|
|
46
62
|
warn USAGE
|
|
47
63
|
1
|
|
@@ -109,6 +125,21 @@ module Mammoth
|
|
|
109
125
|
raise ConfigurationError, "invalid event JSON in #{event_path}: #{e.message}"
|
|
110
126
|
end
|
|
111
127
|
|
|
128
|
+
# Dispatch the nested dead-letter command group.
|
|
129
|
+
#
|
|
130
|
+
# @return [Integer] process status code
|
|
131
|
+
def dead_letters
|
|
132
|
+
DeadLetterCommands.call(argv)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def observability
|
|
136
|
+
config = load_config
|
|
137
|
+
server = ObservabilityServer.new(config)
|
|
138
|
+
puts "Mammoth observability listening on #{server.host}:#{server.port}"
|
|
139
|
+
server.start
|
|
140
|
+
0
|
|
141
|
+
end
|
|
142
|
+
|
|
112
143
|
def load_config
|
|
113
144
|
raise ConfigurationError, "configuration path required\n#{USAGE}" unless config_path
|
|
114
145
|
|
|
@@ -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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "webrick"
|
|
5
|
+
|
|
6
|
+
module Mammoth
|
|
7
|
+
# Small HTTP server exposing Mammoth health, readiness, and metrics endpoints.
|
|
8
|
+
#
|
|
9
|
+
# The server is intentionally independent from the replication loop. Operators
|
|
10
|
+
# may run it as a sidecar-like process or in a separate process that points at
|
|
11
|
+
# the same SQLite operational database.
|
|
12
|
+
class ObservabilityServer
|
|
13
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
14
|
+
DEFAULT_PORT = 9393
|
|
15
|
+
|
|
16
|
+
attr_reader :config, :host, :port, :sqlite_store, :logger, :server
|
|
17
|
+
|
|
18
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
19
|
+
# @param host [String, nil] bind host override
|
|
20
|
+
# @param port [Integer, nil] bind port override
|
|
21
|
+
# @param sqlite_store [Mammoth::SQLiteStore, nil] optional operational store
|
|
22
|
+
# @param logger [WEBrick::Log, nil] optional WEBrick logger
|
|
23
|
+
def initialize(config, host: nil, port: nil, sqlite_store: nil, logger: nil)
|
|
24
|
+
@config = config
|
|
25
|
+
@host = host || config.dig("observability", "host") || DEFAULT_HOST
|
|
26
|
+
@port = port || config.dig("observability", "port") || DEFAULT_PORT
|
|
27
|
+
@sqlite_store = sqlite_store
|
|
28
|
+
@logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
|
|
29
|
+
@server = build_server
|
|
30
|
+
mount_endpoints
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Start the blocking HTTP server.
|
|
34
|
+
#
|
|
35
|
+
# @return [void]
|
|
36
|
+
def start
|
|
37
|
+
server.start
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Stop the HTTP server.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
def shutdown
|
|
44
|
+
server.shutdown
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_server
|
|
50
|
+
WEBrick::HTTPServer.new(
|
|
51
|
+
BindAddress: host,
|
|
52
|
+
Port: port,
|
|
53
|
+
Logger: logger,
|
|
54
|
+
AccessLog: []
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mount_endpoints
|
|
59
|
+
server.mount_proc("/healthz") { |_request, response| write_json(response, snapshot.health, status: 200) }
|
|
60
|
+
server.mount_proc("/readyz") { |_request, response| write_readiness(response) }
|
|
61
|
+
server.mount_proc("/metrics") { |_request, response| write_metrics(response) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write_readiness(response)
|
|
65
|
+
payload = snapshot.readiness
|
|
66
|
+
write_json(response, payload, status: payload.fetch(:status) == "ready" ? 200 : 503)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write_metrics(response)
|
|
70
|
+
response.status = 200
|
|
71
|
+
response["Content-Type"] = "text/plain; version=0.0.4; charset=utf-8"
|
|
72
|
+
response.body = snapshot.prometheus
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_json(response, payload, status:)
|
|
76
|
+
response.status = status
|
|
77
|
+
response["Content-Type"] = "application/json"
|
|
78
|
+
response.body = JSON.generate(payload)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def snapshot
|
|
82
|
+
ObservabilitySnapshot.new(config, sqlite_store: sqlite_store)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Mammoth
|
|
6
|
+
# Builds health, readiness, and metrics snapshots from Mammoth's local
|
|
7
|
+
# operational state.
|
|
8
|
+
#
|
|
9
|
+
# ObservabilitySnapshot is intentionally read-only. It does not start the
|
|
10
|
+
# relay, mutate checkpoints, replay dead letters, or inspect PostgreSQL. The
|
|
11
|
+
# health and metrics endpoints use this object to expose Mammoth process and
|
|
12
|
+
# SQLite operational-state status in a predictable format.
|
|
13
|
+
class ObservabilitySnapshot
|
|
14
|
+
attr_reader :config, :sqlite_store, :clock
|
|
15
|
+
|
|
16
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
17
|
+
# @param sqlite_store [Mammoth::SQLiteStore, nil] optional operational store
|
|
18
|
+
# @param clock [#call] time source returning a Time-like object
|
|
19
|
+
def initialize(config, sqlite_store: nil, clock: -> { Time.now.utc })
|
|
20
|
+
@config = config
|
|
21
|
+
@sqlite_store = sqlite_store
|
|
22
|
+
@clock = clock
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Build a liveness response.
|
|
26
|
+
#
|
|
27
|
+
# @return [Hash] health payload
|
|
28
|
+
def health
|
|
29
|
+
{
|
|
30
|
+
status: "ok",
|
|
31
|
+
service: "mammoth",
|
|
32
|
+
name: mammoth_name,
|
|
33
|
+
version: Mammoth::VERSION,
|
|
34
|
+
checked_at: checked_at
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Build a readiness response.
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] readiness payload
|
|
41
|
+
def readiness
|
|
42
|
+
store = operational_store
|
|
43
|
+
store.bootstrap!
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
status: "ready",
|
|
47
|
+
service: "mammoth",
|
|
48
|
+
name: mammoth_name,
|
|
49
|
+
sqlite: "ok",
|
|
50
|
+
tables: store.tables,
|
|
51
|
+
checked_at: checked_at
|
|
52
|
+
}
|
|
53
|
+
rescue Mammoth::Error, SQLite3::Exception => e
|
|
54
|
+
{
|
|
55
|
+
status: "unready",
|
|
56
|
+
service: "mammoth",
|
|
57
|
+
name: mammoth_name,
|
|
58
|
+
sqlite: "error",
|
|
59
|
+
error_class: e.class.name,
|
|
60
|
+
error_message: e.message,
|
|
61
|
+
checked_at: checked_at
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build a Prometheus text exposition document.
|
|
66
|
+
#
|
|
67
|
+
# @return [String] Prometheus metrics text
|
|
68
|
+
def prometheus
|
|
69
|
+
store = operational_store.bootstrap!
|
|
70
|
+
checkpoint_store = CheckpointStore.new(store)
|
|
71
|
+
dead_letter_store = DeadLetterStore.new(store)
|
|
72
|
+
delivered_store = DeliveredEnvelopeStore.new(store)
|
|
73
|
+
|
|
74
|
+
lines = metric_headers + [
|
|
75
|
+
metric_line("mammoth_up", 1),
|
|
76
|
+
metric_line("mammoth_checkpoints_total", checkpoint_store.count),
|
|
77
|
+
metric_line("mammoth_dead_letters_total", dead_letter_store.count),
|
|
78
|
+
metric_line("mammoth_dead_letters_pending_total", dead_letter_store.count(status: "pending")),
|
|
79
|
+
metric_line("mammoth_dead_letters_resolved_total", dead_letter_store.count(status: "resolved")),
|
|
80
|
+
metric_line("mammoth_dead_letters_ignored_total", dead_letter_store.count(status: "ignored")),
|
|
81
|
+
metric_line("mammoth_delivered_envelopes_total", delivered_store.count)
|
|
82
|
+
]
|
|
83
|
+
"#{lines.join("\n")}\n"
|
|
84
|
+
rescue Mammoth::Error, SQLite3::Exception
|
|
85
|
+
"#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def operational_store
|
|
91
|
+
sqlite_store || SQLiteStore.connect(config.dig("sqlite", "path"))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mammoth_name
|
|
95
|
+
config.dig("mammoth", "name")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def checked_at
|
|
99
|
+
clock.call.utc.iso8601
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def metric_headers
|
|
103
|
+
[
|
|
104
|
+
"# HELP mammoth_up 1 when Mammoth operational state can be inspected, 0 otherwise.",
|
|
105
|
+
"# TYPE mammoth_up gauge",
|
|
106
|
+
"# HELP mammoth_checkpoints_total Number of checkpoint rows stored by Mammoth.",
|
|
107
|
+
"# TYPE mammoth_checkpoints_total gauge",
|
|
108
|
+
"# HELP mammoth_dead_letters_total Number of dead-letter rows stored by Mammoth.",
|
|
109
|
+
"# TYPE mammoth_dead_letters_total gauge",
|
|
110
|
+
"# HELP mammoth_dead_letters_pending_total Number of pending dead-letter rows.",
|
|
111
|
+
"# TYPE mammoth_dead_letters_pending_total gauge",
|
|
112
|
+
"# HELP mammoth_dead_letters_resolved_total Number of resolved dead-letter rows.",
|
|
113
|
+
"# TYPE mammoth_dead_letters_resolved_total gauge",
|
|
114
|
+
"# HELP mammoth_dead_letters_ignored_total Number of ignored dead-letter rows.",
|
|
115
|
+
"# TYPE mammoth_dead_letters_ignored_total gauge",
|
|
116
|
+
"# HELP mammoth_delivered_envelopes_total Number of delivered-envelope ledger rows.",
|
|
117
|
+
"# TYPE mammoth_delivered_envelopes_total gauge"
|
|
118
|
+
]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def metric_line(name, value)
|
|
122
|
+
%(#{name}{mammoth_name="#{escape_label(mammoth_name)}"} #{Integer(value)})
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def escape_label(value)
|
|
126
|
+
value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
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
|
@@ -4,6 +4,8 @@ require_relative "mammoth/version"
|
|
|
4
4
|
require_relative "mammoth/errors"
|
|
5
5
|
require_relative "mammoth/configuration"
|
|
6
6
|
require_relative "mammoth/status"
|
|
7
|
+
require_relative "mammoth/observability_snapshot"
|
|
8
|
+
require_relative "mammoth/observability_server"
|
|
7
9
|
require_relative "mammoth/sqlite_store"
|
|
8
10
|
require_relative "mammoth/checkpoint_store"
|
|
9
11
|
require_relative "mammoth/dead_letter_store"
|
|
@@ -18,6 +20,7 @@ require_relative "mammoth/sources/postgres"
|
|
|
18
20
|
require_relative "mammoth/cdc_source"
|
|
19
21
|
require_relative "mammoth/replication_consumer"
|
|
20
22
|
require_relative "mammoth/application"
|
|
23
|
+
require_relative "mammoth/dead_letter_commands"
|
|
21
24
|
require_relative "mammoth/cli"
|
|
22
25
|
|
|
23
26
|
# 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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -150,12 +150,15 @@ 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
|
|
156
157
|
- lib/mammoth/delivery_worker.rb
|
|
157
158
|
- lib/mammoth/errors.rb
|
|
158
159
|
- lib/mammoth/event_serializer.rb
|
|
160
|
+
- lib/mammoth/observability_server.rb
|
|
161
|
+
- lib/mammoth/observability_snapshot.rb
|
|
159
162
|
- lib/mammoth/replication_consumer.rb
|
|
160
163
|
- lib/mammoth/sources/postgres.rb
|
|
161
164
|
- lib/mammoth/sql/__bootstrap__.sql
|