mammoth 0.5.1 → 0.6.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 +21 -1
- data/config/mammoth.example.yml +21 -0
- data/config/mammoth.schema.json +60 -1
- data/lib/mammoth/application.rb +25 -6
- data/lib/mammoth/cli.rb +4 -2
- data/lib/mammoth/dead_letter_commands.rb +93 -10
- data/lib/mammoth/dead_letter_store.rb +44 -17
- data/lib/mammoth/delivered_envelope_store.rb +12 -2
- data/lib/mammoth/delivery_worker.rb +35 -6
- data/lib/mammoth/observability_snapshot.rb +41 -7
- data/lib/mammoth/route_filter.rb +49 -0
- data/lib/mammoth/version.rb +1 -1
- 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: b1249f6166082904d6ecac8d74819442681eaac72ae1e23c80a2134c412e235a
|
|
4
|
+
data.tar.gz: 292272e0696a218c3a75d970476efa3acaa71d54164da139aefa007bbd256ae5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e401e3a8a1778ac6b63338636cc15d5c06ccbeea77e2179af1189ba8d60d1005a2281884e3cc3a09208ac4836f37d94132ab16c3f00b08edfb1c3347fe0e944
|
|
7
|
+
data.tar.gz: fe8efe54a12f5dae1960eb4cf34c91b1eacd1d91a8609c082349b2a4e900ebda7f5160052b8f7d3bf062b09d16bb84438712f0f7d514ff23b4b16606da8b279a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.6.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added fanout route filters by schema, table, and operation for webhook destinations.
|
|
10
|
+
- Added destination `enabled` controls for config-driven delivery cutovers.
|
|
11
|
+
- Added per-destination retry policy overrides for `max_attempts` and `schedule_seconds`.
|
|
12
|
+
- Added dead-letter replay filters by destination, status, and failed-at time window.
|
|
13
|
+
- Added destination-labeled Prometheus metrics for dead-letter and delivered-envelope counts.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Bumped Mammoth version to `0.6.0`.
|
|
18
|
+
|
|
5
19
|
## 0.5.1
|
|
6
20
|
|
|
7
21
|
### Added
|
data/README.md
CHANGED
|
@@ -47,8 +47,10 @@ https://kanutocd.github.io/mammoth/Mammoth.html
|
|
|
47
47
|
- dead letter persistence
|
|
48
48
|
- webhook delivery sink
|
|
49
49
|
- webhook fanout to multiple destinations
|
|
50
|
+
- fanout route filters by schema, table, and operation
|
|
51
|
+
- per-destination enable/disable and retry policy controls
|
|
50
52
|
- delivery worker with retry, checkpoint, and DLQ handling
|
|
51
|
-
- dead-letter inspection and replay commands
|
|
53
|
+
- dead-letter inspection and filtered replay commands
|
|
52
54
|
- CDC-core event serialization boundary
|
|
53
55
|
- CDC Ecosystem source-adapter integration boundary
|
|
54
56
|
- Docker image support
|
|
@@ -78,6 +80,24 @@ Validate configuration:
|
|
|
78
80
|
bundle exec ./exe/mammoth validate config/mammoth.example.yml
|
|
79
81
|
```
|
|
80
82
|
|
|
83
|
+
Fanout destinations can be routed and tuned independently:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
destinations:
|
|
87
|
+
- name: audit_webhook
|
|
88
|
+
type: webhook
|
|
89
|
+
enabled: true
|
|
90
|
+
url: https://audit.example.com/cdc
|
|
91
|
+
timeout_seconds: 5
|
|
92
|
+
route:
|
|
93
|
+
schemas: [public]
|
|
94
|
+
tables: [orders]
|
|
95
|
+
operations: [insert, update]
|
|
96
|
+
retry:
|
|
97
|
+
max_attempts: 3
|
|
98
|
+
schedule_seconds: [1, 10]
|
|
99
|
+
```
|
|
100
|
+
|
|
81
101
|
## CLI
|
|
82
102
|
|
|
83
103
|
```bash
|
data/config/mammoth.example.yml
CHANGED
|
@@ -275,6 +275,7 @@ webhook:
|
|
|
275
275
|
# destinations:
|
|
276
276
|
# - name: primary_webhook
|
|
277
277
|
# type: webhook
|
|
278
|
+
# enabled: true
|
|
278
279
|
# url: https://example.com/webhooks/postgres
|
|
279
280
|
# timeout_seconds: 5
|
|
280
281
|
# header_env:
|
|
@@ -284,8 +285,28 @@ webhook:
|
|
|
284
285
|
# secret_env: MAMMOTH_PRIMARY_WEBHOOK_SIGNING_SECRET
|
|
285
286
|
# - name: audit_webhook
|
|
286
287
|
# type: webhook
|
|
288
|
+
# enabled: true
|
|
287
289
|
# url: https://audit.example.com/cdc
|
|
288
290
|
# timeout_seconds: 5
|
|
291
|
+
#
|
|
292
|
+
# # Optional route filter. Omit route to receive every event/envelope.
|
|
293
|
+
# # For transaction delivery, a destination matches when any event in the
|
|
294
|
+
# # transaction matches; Mammoth still delivers the full transaction envelope.
|
|
295
|
+
# route:
|
|
296
|
+
# schemas:
|
|
297
|
+
# - public
|
|
298
|
+
# tables:
|
|
299
|
+
# - orders
|
|
300
|
+
# operations:
|
|
301
|
+
# - insert
|
|
302
|
+
# - update
|
|
303
|
+
#
|
|
304
|
+
# # Optional per-destination retry override. Omit to use top-level retry.
|
|
305
|
+
# retry:
|
|
306
|
+
# max_attempts: 3
|
|
307
|
+
# schedule_seconds:
|
|
308
|
+
# - 1
|
|
309
|
+
# - 10
|
|
289
310
|
|
|
290
311
|
retry:
|
|
291
312
|
# Maximum delivery attempts before the event/envelope is moved to the dead
|
data/config/mammoth.schema.json
CHANGED
|
@@ -223,7 +223,11 @@
|
|
|
223
223
|
"enum": [
|
|
224
224
|
"webhook"
|
|
225
225
|
],
|
|
226
|
-
"description": "Destination adapter type. Mammoth OSS 0.
|
|
226
|
+
"description": "Destination adapter type. Mammoth OSS 0.6.0 supports webhook destinations."
|
|
227
|
+
},
|
|
228
|
+
"enabled": {
|
|
229
|
+
"type": "boolean",
|
|
230
|
+
"description": "When false, Mammoth skips this destination, advances local progress for the work item, and does not attempt delivery."
|
|
227
231
|
},
|
|
228
232
|
"url": {
|
|
229
233
|
"type": "string",
|
|
@@ -281,6 +285,61 @@
|
|
|
281
285
|
}
|
|
282
286
|
},
|
|
283
287
|
"description": "Optional HMAC-SHA256 request signing for this webhook destination."
|
|
288
|
+
},
|
|
289
|
+
"route": {
|
|
290
|
+
"type": "object",
|
|
291
|
+
"additionalProperties": false,
|
|
292
|
+
"properties": {
|
|
293
|
+
"schemas": {
|
|
294
|
+
"type": "array",
|
|
295
|
+
"minItems": 1,
|
|
296
|
+
"items": {
|
|
297
|
+
"type": "string",
|
|
298
|
+
"minLength": 1
|
|
299
|
+
},
|
|
300
|
+
"description": "PostgreSQL schemas this destination should receive. Omit to match every schema."
|
|
301
|
+
},
|
|
302
|
+
"tables": {
|
|
303
|
+
"type": "array",
|
|
304
|
+
"minItems": 1,
|
|
305
|
+
"items": {
|
|
306
|
+
"type": "string",
|
|
307
|
+
"minLength": 1
|
|
308
|
+
},
|
|
309
|
+
"description": "PostgreSQL tables this destination should receive. Omit to match every table."
|
|
310
|
+
},
|
|
311
|
+
"operations": {
|
|
312
|
+
"type": "array",
|
|
313
|
+
"minItems": 1,
|
|
314
|
+
"items": {
|
|
315
|
+
"type": "string",
|
|
316
|
+
"minLength": 1
|
|
317
|
+
},
|
|
318
|
+
"description": "CDC operations this destination should receive, such as insert, update, or delete. Omit to match every operation."
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
"description": "Optional destination route filter. Event delivery matches one event; transaction delivery matches when any event in the transaction matches and delivers the full envelope."
|
|
322
|
+
},
|
|
323
|
+
"retry": {
|
|
324
|
+
"type": "object",
|
|
325
|
+
"additionalProperties": false,
|
|
326
|
+
"properties": {
|
|
327
|
+
"max_attempts": {
|
|
328
|
+
"type": "integer",
|
|
329
|
+
"minimum": 1,
|
|
330
|
+
"description": "Destination-specific maximum delivery attempts before dead-lettering. Defaults to the top-level retry.max_attempts."
|
|
331
|
+
},
|
|
332
|
+
"schedule_seconds": {
|
|
333
|
+
"type": "array",
|
|
334
|
+
"minItems": 1,
|
|
335
|
+
"items": {
|
|
336
|
+
"type": "integer",
|
|
337
|
+
"minimum": 1
|
|
338
|
+
},
|
|
339
|
+
"description": "Destination-specific retry backoff schedule in seconds. Defaults to the top-level retry.schedule_seconds."
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
"description": "Optional retry policy override for this destination."
|
|
284
343
|
}
|
|
285
344
|
}
|
|
286
345
|
},
|
data/lib/mammoth/application.rb
CHANGED
|
@@ -87,32 +87,51 @@ module Mammoth
|
|
|
87
87
|
Sources::Postgres.new(config, checkpoint_store: checkpoint_store)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
def build_delivery_worker(sink:, sleeper:)
|
|
90
|
+
def build_delivery_worker(sink:, sleeper:, delivery_policy: {})
|
|
91
91
|
DeliveryWorker.from_config(
|
|
92
92
|
config,
|
|
93
93
|
sink: sink,
|
|
94
94
|
checkpoint_store: checkpoint_store,
|
|
95
95
|
dead_letter_store: DeadLetterStore.new(sqlite_store),
|
|
96
|
-
sleeper: sleeper
|
|
96
|
+
sleeper: sleeper,
|
|
97
|
+
delivery_policy: delivery_policy
|
|
97
98
|
)
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
def build_configured_delivery_worker(sleeper:)
|
|
101
|
-
workers =
|
|
102
|
+
workers = destination_specs.map do |spec|
|
|
103
|
+
build_delivery_worker(sink: spec.fetch(:sink), sleeper:, delivery_policy: spec.fetch(:delivery_policy))
|
|
104
|
+
end
|
|
102
105
|
return workers.fetch(0) if workers.one?
|
|
103
106
|
|
|
104
107
|
FanoutDeliveryWorker.new(workers)
|
|
105
108
|
end
|
|
106
109
|
|
|
107
|
-
def
|
|
110
|
+
def destination_specs
|
|
108
111
|
destinations = config.data["destinations"]
|
|
109
|
-
|
|
112
|
+
unless destinations
|
|
113
|
+
delivery_policy = {} # : Hash[String, untyped]
|
|
114
|
+
return [{ sink: WebhookSink.from_config(config), delivery_policy: delivery_policy }]
|
|
115
|
+
end
|
|
110
116
|
|
|
111
117
|
destinations.map.with_index(1) do |destination, index|
|
|
112
|
-
|
|
118
|
+
{
|
|
119
|
+
sink: WebhookSink.from_destination_config(destination, label: "destinations[#{index - 1}]"),
|
|
120
|
+
delivery_policy: destination_delivery_policy(destination)
|
|
121
|
+
}
|
|
113
122
|
end
|
|
114
123
|
end
|
|
115
124
|
|
|
125
|
+
def destination_delivery_policy(destination)
|
|
126
|
+
retry_config = destination["retry"] || {}
|
|
127
|
+
{
|
|
128
|
+
"enabled" => destination.fetch("enabled", true),
|
|
129
|
+
"max_attempts" => retry_config.fetch("max_attempts", config.dig("retry", "max_attempts")),
|
|
130
|
+
"schedule_seconds" => retry_config.fetch("schedule_seconds", config.dig("retry", "schedule_seconds")),
|
|
131
|
+
"route_filter" => RouteFilter.new(destination["route"])
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
116
135
|
def transaction_delivery?
|
|
117
136
|
delivery_unit == :transaction
|
|
118
137
|
end
|
data/lib/mammoth/cli.rb
CHANGED
|
@@ -24,9 +24,11 @@ module Mammoth
|
|
|
24
24
|
" mammoth status CONFIG",
|
|
25
25
|
" mammoth start CONFIG",
|
|
26
26
|
" mammoth deliver-sample CONFIG EVENT_JSON",
|
|
27
|
-
" mammoth dead-letters list CONFIG [--status STATUS] [--
|
|
27
|
+
" mammoth dead-letters list CONFIG [--status STATUS] [--destination NAME] " \
|
|
28
|
+
"[--failed-after ISO8601] [--failed-before ISO8601] [--limit N]",
|
|
28
29
|
" mammoth dead-letters show CONFIG ID",
|
|
29
|
-
" mammoth dead-letters replay CONFIG [ID ...]"
|
|
30
|
+
" mammoth dead-letters replay CONFIG [ID ...] [--destination NAME] [--status STATUS] " \
|
|
31
|
+
"[--failed-after ISO8601] [--failed-before ISO8601] [--limit N]",
|
|
30
32
|
" mammoth observability CONFIG"
|
|
31
33
|
].join("\n")
|
|
32
34
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Mammoth
|
|
6
7
|
# Operator commands for inspecting and replaying dead letters.
|
|
@@ -55,7 +56,14 @@ module Mammoth
|
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def list
|
|
58
|
-
|
|
59
|
+
options = list_options
|
|
60
|
+
rows = dead_letter_store.rows(
|
|
61
|
+
status: options.fetch(:status),
|
|
62
|
+
destination: options.fetch(:destination),
|
|
63
|
+
failed_after: options.fetch(:failed_after),
|
|
64
|
+
failed_before: options.fetch(:failed_before),
|
|
65
|
+
limit: options.fetch(:limit)
|
|
66
|
+
)
|
|
59
67
|
puts list_header
|
|
60
68
|
rows.each { |row| puts list_row(row) }
|
|
61
69
|
0
|
|
@@ -73,7 +81,7 @@ module Mammoth
|
|
|
73
81
|
|
|
74
82
|
rows.each do |row|
|
|
75
83
|
result = replay_row(row)
|
|
76
|
-
dead_letter_store.resolve(row.fetch("id"))
|
|
84
|
+
dead_letter_store.resolve(row.fetch("id")) if replay_resolved?(result)
|
|
77
85
|
puts replay_message(row, result)
|
|
78
86
|
end
|
|
79
87
|
0
|
|
@@ -106,7 +114,7 @@ module Mammoth
|
|
|
106
114
|
#
|
|
107
115
|
# @return [Hash] list options
|
|
108
116
|
def list_options
|
|
109
|
-
options = { status: "pending", limit: 100 }
|
|
117
|
+
options = { status: "pending", destination: nil, failed_after: nil, failed_before: nil, limit: 100 }
|
|
110
118
|
index = 0
|
|
111
119
|
args = argv.drop(3)
|
|
112
120
|
|
|
@@ -122,17 +130,44 @@ module Mammoth
|
|
|
122
130
|
def parse_list_option(args, index, options)
|
|
123
131
|
case args.fetch(index)
|
|
124
132
|
when "--status"
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
assign_option(args, index, options, :status)
|
|
134
|
+
when "--destination"
|
|
135
|
+
assign_option(args, index, options, :destination)
|
|
136
|
+
when "--failed-after"
|
|
137
|
+
assign_time_option(args, index, options, :failed_after)
|
|
138
|
+
when "--failed-before"
|
|
139
|
+
assign_time_option(args, index, options, :failed_before)
|
|
127
140
|
when "--limit"
|
|
128
|
-
|
|
129
|
-
index + 2
|
|
141
|
+
assign_limit_option(args, index, options)
|
|
130
142
|
else
|
|
131
143
|
raise ConfigurationError, "unknown option #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
|
|
132
144
|
|
|
133
145
|
raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}"
|
|
134
146
|
end
|
|
135
|
-
rescue ArgumentError
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
raise ConfigurationError, "invalid dead letter list option"
|
|
149
|
+
rescue IndexError
|
|
150
|
+
raise ConfigurationError, "missing value for dead letter option"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def assign_option(args, index, options, key)
|
|
154
|
+
options[key] = args.fetch(index + 1)
|
|
155
|
+
index + 2
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def assign_time_option(args, index, options, key)
|
|
159
|
+
options[key] = parse_time_option(args.fetch(index + 1), args.fetch(index))
|
|
160
|
+
index + 2
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def assign_limit_option(args, index, options)
|
|
164
|
+
options[:limit] = parse_limit_option(args.fetch(index + 1))
|
|
165
|
+
index + 2
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse_limit_option(value)
|
|
169
|
+
Integer(value)
|
|
170
|
+
rescue ArgumentError
|
|
136
171
|
raise ConfigurationError, "dead letter limit must be an integer"
|
|
137
172
|
end
|
|
138
173
|
|
|
@@ -140,8 +175,17 @@ module Mammoth
|
|
|
140
175
|
#
|
|
141
176
|
# @return [Array<Hash>] replay rows
|
|
142
177
|
def replay_rows
|
|
143
|
-
|
|
144
|
-
|
|
178
|
+
options = replay_options
|
|
179
|
+
ids = options.fetch(:ids)
|
|
180
|
+
if ids.empty?
|
|
181
|
+
return dead_letter_store.rows(
|
|
182
|
+
status: options.fetch(:status),
|
|
183
|
+
destination: options.fetch(:destination),
|
|
184
|
+
failed_after: options.fetch(:failed_after),
|
|
185
|
+
failed_before: options.fetch(:failed_before),
|
|
186
|
+
limit: options.fetch(:limit)
|
|
187
|
+
)
|
|
188
|
+
end
|
|
145
189
|
|
|
146
190
|
ids.map do |raw_id|
|
|
147
191
|
id = Integer(raw_id)
|
|
@@ -154,6 +198,41 @@ module Mammoth
|
|
|
154
198
|
end
|
|
155
199
|
end
|
|
156
200
|
|
|
201
|
+
def parse_time_option(value, option_name)
|
|
202
|
+
Time.iso8601(value).utc.iso8601
|
|
203
|
+
rescue ArgumentError
|
|
204
|
+
raise ConfigurationError, "#{option_name} must be an ISO-8601 timestamp"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def replay_options
|
|
208
|
+
ids = [] # : Array[Integer]
|
|
209
|
+
options = { ids: ids, status: "pending", destination: nil, failed_after: nil, failed_before: nil, limit: 100 }
|
|
210
|
+
index = 0
|
|
211
|
+
args = argv.drop(3)
|
|
212
|
+
|
|
213
|
+
# rubocop:disable Style/WhileUntilModifier
|
|
214
|
+
while index < args.length
|
|
215
|
+
index = parse_replay_option(args, index, options)
|
|
216
|
+
end
|
|
217
|
+
# rubocop:enable Style/WhileUntilModifier
|
|
218
|
+
|
|
219
|
+
options
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def parse_replay_option(args, index, options)
|
|
223
|
+
case args.fetch(index)
|
|
224
|
+
when "--status", "--destination", "--failed-after", "--failed-before", "--limit"
|
|
225
|
+
parse_list_option(args, index, options)
|
|
226
|
+
else
|
|
227
|
+
raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
|
|
228
|
+
|
|
229
|
+
options[:ids] << Integer(args.fetch(index))
|
|
230
|
+
index + 1
|
|
231
|
+
end
|
|
232
|
+
rescue ArgumentError
|
|
233
|
+
raise ConfigurationError, "dead letter id must be an integer"
|
|
234
|
+
end
|
|
235
|
+
|
|
157
236
|
def fetch_dead_letter!(id)
|
|
158
237
|
row = dead_letter_store.fetch(id)
|
|
159
238
|
raise ConfigurationError, "dead letter not found: #{id}" unless row
|
|
@@ -182,6 +261,10 @@ module Mammoth
|
|
|
182
261
|
worker.deliver_transaction(envelope)
|
|
183
262
|
end
|
|
184
263
|
|
|
264
|
+
def replay_resolved?(result)
|
|
265
|
+
result.fetch(:status) == "delivered" || result.fetch(:duplicate, false)
|
|
266
|
+
end
|
|
267
|
+
|
|
185
268
|
def transaction_payload?(payload)
|
|
186
269
|
payload.fetch("type", nil) == TransactionEnvelopeSerializer::PAYLOAD_TYPE
|
|
187
270
|
end
|
|
@@ -65,8 +65,9 @@ module Mammoth
|
|
|
65
65
|
#
|
|
66
66
|
# @param limit [Integer] maximum number of rows
|
|
67
67
|
# @return [Array<Hash>] pending dead letter rows
|
|
68
|
-
def pending(limit: 100)
|
|
69
|
-
rows(status: "pending", limit: limit
|
|
68
|
+
def pending(limit: 100, destination: nil, failed_after: nil, failed_before: nil)
|
|
69
|
+
rows(status: "pending", limit: limit, destination: destination, failed_after: failed_after,
|
|
70
|
+
failed_before: failed_before)
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# Fetch dead letters, optionally filtered by status.
|
|
@@ -74,15 +75,12 @@ module Mammoth
|
|
|
74
75
|
# @param status [String, nil] optional dead-letter status filter
|
|
75
76
|
# @param limit [Integer] maximum number of rows
|
|
76
77
|
# @return [Array<Hash>] dead letter rows
|
|
77
|
-
def rows(status: nil, limit: 100)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
database.execute("SELECT * FROM dead_letters ORDER BY failed_at ASC LIMIT ?", [limit])
|
|
78
|
+
def rows(status: nil, limit: 100, destination: nil, failed_after: nil, failed_before: nil)
|
|
79
|
+
where, values = row_filters(status:, destination:, failed_after:, failed_before:)
|
|
80
|
+
database.execute(
|
|
81
|
+
"SELECT * FROM dead_letters#{where} ORDER BY failed_at ASC LIMIT ?",
|
|
82
|
+
values + [limit]
|
|
83
|
+
)
|
|
86
84
|
end
|
|
87
85
|
|
|
88
86
|
# Fetch one dead letter by id.
|
|
@@ -97,12 +95,17 @@ module Mammoth
|
|
|
97
95
|
#
|
|
98
96
|
# @param status [String, nil] optional status
|
|
99
97
|
# @return [Integer] dead letter count
|
|
100
|
-
def count(status: nil)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
98
|
+
def count(status: nil, destination: nil)
|
|
99
|
+
where, values = row_filters(status:, destination:)
|
|
100
|
+
database.get_first_value("SELECT COUNT(*) FROM dead_letters#{where}", values)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def counts_by_destination(status: nil)
|
|
104
|
+
where, values = row_filters(status:)
|
|
105
|
+
database.execute(
|
|
106
|
+
"SELECT destination_name, COUNT(*) AS count FROM dead_letters#{where} GROUP BY destination_name",
|
|
107
|
+
values
|
|
108
|
+
)
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
# Mark a dead letter as resolved.
|
|
@@ -133,5 +136,29 @@ module Mammoth
|
|
|
133
136
|
[status, Time.now.utc.iso8601, id]
|
|
134
137
|
)
|
|
135
138
|
end
|
|
139
|
+
|
|
140
|
+
def row_filters(status: nil, destination: nil, failed_after: nil, failed_before: nil)
|
|
141
|
+
predicates = [] # : Array[String]
|
|
142
|
+
values = [] # : Array[untyped]
|
|
143
|
+
|
|
144
|
+
unless status.nil? || status == "all"
|
|
145
|
+
predicates << "status = ?"
|
|
146
|
+
values << status
|
|
147
|
+
end
|
|
148
|
+
if destination
|
|
149
|
+
predicates << "destination_name = ?"
|
|
150
|
+
values << destination
|
|
151
|
+
end
|
|
152
|
+
if failed_after
|
|
153
|
+
predicates << "failed_at >= ?"
|
|
154
|
+
values << failed_after
|
|
155
|
+
end
|
|
156
|
+
if failed_before
|
|
157
|
+
predicates << "failed_at <= ?"
|
|
158
|
+
values << failed_before
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
[predicates.empty? ? "" : " WHERE #{predicates.join(" AND ")}", values]
|
|
162
|
+
end
|
|
136
163
|
end
|
|
137
164
|
end
|
|
@@ -85,8 +85,18 @@ module Mammoth
|
|
|
85
85
|
# Count delivered envelopes.
|
|
86
86
|
#
|
|
87
87
|
# @return [Integer] delivered envelope count
|
|
88
|
-
def count
|
|
89
|
-
|
|
88
|
+
def count(destination: nil)
|
|
89
|
+
if destination
|
|
90
|
+
database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes WHERE destination_name = ?", [destination])
|
|
91
|
+
else
|
|
92
|
+
database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def counts_by_destination
|
|
97
|
+
database.execute(
|
|
98
|
+
"SELECT destination_name, COUNT(*) AS count FROM delivered_envelopes GROUP BY destination_name"
|
|
99
|
+
)
|
|
90
100
|
end
|
|
91
101
|
|
|
92
102
|
private
|
|
@@ -12,7 +12,7 @@ module Mammoth
|
|
|
12
12
|
DEFAULT_SOURCE = "postgresql"
|
|
13
13
|
|
|
14
14
|
attr_reader :sink, :checkpoint_store, :dead_letter_store, :delivered_envelope_store, :retry_schedule, :max_attempts,
|
|
15
|
-
:sleeper, :source_name, :slot_name, :publication_name
|
|
15
|
+
:sleeper, :source_name, :slot_name, :publication_name, :route_filter, :enabled
|
|
16
16
|
|
|
17
17
|
# @param sink [#deliver] destination sink
|
|
18
18
|
# @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
|
|
@@ -24,8 +24,11 @@ module Mammoth
|
|
|
24
24
|
# @param max_attempts [Integer] maximum delivery attempts
|
|
25
25
|
# @param retry_schedule [Array<Integer>] retry wait schedule in seconds
|
|
26
26
|
# @param sleeper [#call] sleep strategy, injectable for tests
|
|
27
|
+
# @param route_filter [Mammoth::RouteFilter, nil] optional destination route matcher
|
|
28
|
+
# @param enabled [Boolean] whether this destination accepts new deliveries
|
|
27
29
|
def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:, publication_name:,
|
|
28
|
-
max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep)
|
|
30
|
+
max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep),
|
|
31
|
+
route_filter: nil, enabled: true)
|
|
29
32
|
@sink = sink
|
|
30
33
|
@checkpoint_store = checkpoint_store
|
|
31
34
|
@dead_letter_store = dead_letter_store
|
|
@@ -36,6 +39,8 @@ module Mammoth
|
|
|
36
39
|
@max_attempts = max_attempts
|
|
37
40
|
@retry_schedule = retry_schedule
|
|
38
41
|
@sleeper = sleeper
|
|
42
|
+
@route_filter = route_filter || RouteFilter.new
|
|
43
|
+
@enabled = enabled
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
# Build a delivery worker from Mammoth configuration and stores.
|
|
@@ -46,7 +51,8 @@ module Mammoth
|
|
|
46
51
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
47
52
|
# @param sleeper [#call] sleep strategy
|
|
48
53
|
# @return [Mammoth::DeliveryWorker]
|
|
49
|
-
def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, sleeper: Kernel.method(:sleep)
|
|
54
|
+
def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, sleeper: Kernel.method(:sleep),
|
|
55
|
+
delivery_policy: {})
|
|
50
56
|
new(
|
|
51
57
|
sink: sink,
|
|
52
58
|
checkpoint_store: checkpoint_store,
|
|
@@ -54,9 +60,11 @@ module Mammoth
|
|
|
54
60
|
source_name: config.dig("mammoth", "name"),
|
|
55
61
|
slot_name: config.dig("replication", "slot"),
|
|
56
62
|
publication_name: Array(config.dig("replication", "publications")).join(","),
|
|
57
|
-
max_attempts: config.dig("retry", "max_attempts"),
|
|
58
|
-
retry_schedule: config.dig("retry", "schedule_seconds"),
|
|
59
|
-
sleeper: sleeper
|
|
63
|
+
max_attempts: delivery_policy.fetch("max_attempts", config.dig("retry", "max_attempts")),
|
|
64
|
+
retry_schedule: delivery_policy.fetch("schedule_seconds", config.dig("retry", "schedule_seconds")),
|
|
65
|
+
sleeper: sleeper,
|
|
66
|
+
route_filter: delivery_policy.fetch("route_filter", RouteFilter.new),
|
|
67
|
+
enabled: delivery_policy.fetch("enabled", true)
|
|
60
68
|
)
|
|
61
69
|
end
|
|
62
70
|
|
|
@@ -85,6 +93,9 @@ module Mammoth
|
|
|
85
93
|
delivery_unit = delivery_unit_for(delivery_method)
|
|
86
94
|
idempotency_key = idempotency_key_for(payload:, delivery_unit:)
|
|
87
95
|
|
|
96
|
+
skip_result = skip_result_for(payload, idempotency_key:)
|
|
97
|
+
return skip_result if skip_result
|
|
98
|
+
|
|
88
99
|
if delivered_envelope_store.delivered?(idempotency_key)
|
|
89
100
|
checkpoint_payload(payload)
|
|
90
101
|
return {
|
|
@@ -151,6 +162,24 @@ module Mammoth
|
|
|
151
162
|
sink.respond_to?(:name) ? sink.name : sink.class.name
|
|
152
163
|
end
|
|
153
164
|
|
|
165
|
+
def skip_result_for(payload, idempotency_key:)
|
|
166
|
+
return skipped(payload, idempotency_key:, reason: "disabled") unless enabled
|
|
167
|
+
|
|
168
|
+
skipped(payload, idempotency_key:, reason: "route_mismatch") unless route_filter.match_payload?(payload)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def skipped(payload, idempotency_key:, reason:)
|
|
172
|
+
checkpoint_payload(payload)
|
|
173
|
+
{
|
|
174
|
+
status: "skipped",
|
|
175
|
+
reason: reason,
|
|
176
|
+
duplicate: false,
|
|
177
|
+
idempotency_key: idempotency_key,
|
|
178
|
+
attempts: 0,
|
|
179
|
+
destination: destination_name
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
154
183
|
def dead_letter(event, error, attempts, serializer:)
|
|
155
184
|
id = dead_letter_store.write(
|
|
156
185
|
event: event,
|
|
@@ -71,7 +71,20 @@ module Mammoth
|
|
|
71
71
|
dead_letter_store = DeadLetterStore.new(store)
|
|
72
72
|
delivered_store = DeliveredEnvelopeStore.new(store)
|
|
73
73
|
|
|
74
|
-
lines = metric_headers +
|
|
74
|
+
lines = metric_headers + aggregate_metric_lines(
|
|
75
|
+
checkpoint_store: checkpoint_store,
|
|
76
|
+
dead_letter_store: dead_letter_store,
|
|
77
|
+
delivered_store: delivered_store
|
|
78
|
+
) + destination_metric_lines(dead_letter_store:, delivered_store:)
|
|
79
|
+
"#{lines.join("\n")}\n"
|
|
80
|
+
rescue Mammoth::Error, SQLite3::Exception
|
|
81
|
+
"#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def aggregate_metric_lines(checkpoint_store:, dead_letter_store:, delivered_store:)
|
|
87
|
+
[
|
|
75
88
|
metric_line("mammoth_up", 1),
|
|
76
89
|
metric_line("mammoth_checkpoints_total", checkpoint_store.count),
|
|
77
90
|
metric_line("mammoth_dead_letters_total", dead_letter_store.count),
|
|
@@ -80,12 +93,24 @@ module Mammoth
|
|
|
80
93
|
metric_line("mammoth_dead_letters_ignored_total", dead_letter_store.count(status: "ignored")),
|
|
81
94
|
metric_line("mammoth_delivered_envelopes_total", delivered_store.count)
|
|
82
95
|
]
|
|
83
|
-
"#{lines.join("\n")}\n"
|
|
84
|
-
rescue Mammoth::Error, SQLite3::Exception
|
|
85
|
-
"#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
|
|
86
96
|
end
|
|
87
97
|
|
|
88
|
-
|
|
98
|
+
def destination_metric_lines(dead_letter_store:, delivered_store:)
|
|
99
|
+
destination_names.flat_map do |destination|
|
|
100
|
+
[
|
|
101
|
+
metric_line("mammoth_dead_letters_total", dead_letter_store.count(destination: destination),
|
|
102
|
+
destination: destination),
|
|
103
|
+
metric_line("mammoth_dead_letters_pending_total",
|
|
104
|
+
dead_letter_store.count(status: "pending", destination: destination), destination: destination),
|
|
105
|
+
metric_line("mammoth_dead_letters_resolved_total",
|
|
106
|
+
dead_letter_store.count(status: "resolved", destination: destination), destination: destination),
|
|
107
|
+
metric_line("mammoth_dead_letters_ignored_total",
|
|
108
|
+
dead_letter_store.count(status: "ignored", destination: destination), destination: destination),
|
|
109
|
+
metric_line("mammoth_delivered_envelopes_total", delivered_store.count(destination: destination),
|
|
110
|
+
destination: destination)
|
|
111
|
+
]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
89
114
|
|
|
90
115
|
def operational_store
|
|
91
116
|
sqlite_store || SQLiteStore.connect(config.dig("sqlite", "path"))
|
|
@@ -95,6 +120,13 @@ module Mammoth
|
|
|
95
120
|
config.dig("mammoth", "name")
|
|
96
121
|
end
|
|
97
122
|
|
|
123
|
+
def destination_names
|
|
124
|
+
destinations = config.data["destinations"]
|
|
125
|
+
return destinations.map { |destination| destination.fetch("name") } if destinations
|
|
126
|
+
|
|
127
|
+
[config.dig("webhook", "name")]
|
|
128
|
+
end
|
|
129
|
+
|
|
98
130
|
def checked_at
|
|
99
131
|
clock.call.utc.iso8601
|
|
100
132
|
end
|
|
@@ -118,8 +150,10 @@ module Mammoth
|
|
|
118
150
|
]
|
|
119
151
|
end
|
|
120
152
|
|
|
121
|
-
def metric_line(name, value)
|
|
122
|
-
|
|
153
|
+
def metric_line(name, value, destination: nil)
|
|
154
|
+
labels = { "mammoth_name" => mammoth_name }
|
|
155
|
+
labels["destination"] = destination if destination
|
|
156
|
+
%(#{name}{#{labels.map { |label, label_value| %(#{label}="#{escape_label(label_value)}") }.join(",")}} #{Integer(value)})
|
|
123
157
|
end
|
|
124
158
|
|
|
125
159
|
def escape_label(value)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Matches CDC events and transaction envelopes against destination route rules.
|
|
5
|
+
class RouteFilter
|
|
6
|
+
attr_reader :schemas, :tables, :operations
|
|
7
|
+
|
|
8
|
+
def initialize(config = nil)
|
|
9
|
+
route = config || {}
|
|
10
|
+
@schemas = normalized_set(route["schemas"])
|
|
11
|
+
@tables = normalized_set(route["tables"])
|
|
12
|
+
@operations = normalized_set(route["operations"])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def match?(work, serializer:)
|
|
16
|
+
match_payload?(serializer.call(work))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def match_payload?(payload)
|
|
20
|
+
if transaction_payload?(payload)
|
|
21
|
+
payload.fetch("events").any? { |event| event_match?(event) }
|
|
22
|
+
else
|
|
23
|
+
event_match?(payload)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def normalized_set(values)
|
|
30
|
+
return [] unless values
|
|
31
|
+
|
|
32
|
+
values.map { |value| value.to_s.downcase }.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def transaction_payload?(payload)
|
|
36
|
+
payload.fetch("type", nil) == TransactionEnvelopeSerializer::PAYLOAD_TYPE
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def event_match?(payload)
|
|
40
|
+
matches?(schemas, payload["namespace"]) &&
|
|
41
|
+
matches?(tables, payload["entity"]) &&
|
|
42
|
+
matches?(operations, payload["operation"])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def matches?(allowed_values, actual_value)
|
|
46
|
+
allowed_values.empty? || allowed_values.include?(actual_value.to_s.downcase)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/mammoth/version.rb
CHANGED
data/lib/mammoth.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "mammoth/dead_letter_store"
|
|
|
12
12
|
require_relative "mammoth/delivered_envelope_store"
|
|
13
13
|
require_relative "mammoth/event_serializer"
|
|
14
14
|
require_relative "mammoth/transaction_envelope_serializer"
|
|
15
|
+
require_relative "mammoth/route_filter"
|
|
15
16
|
require_relative "mammoth/webhook_sink"
|
|
16
17
|
require_relative "mammoth/delivery_worker"
|
|
17
18
|
require_relative "mammoth/fanout_delivery_worker"
|
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -161,6 +161,7 @@ files:
|
|
|
161
161
|
- lib/mammoth/observability_server.rb
|
|
162
162
|
- lib/mammoth/observability_snapshot.rb
|
|
163
163
|
- lib/mammoth/replication_consumer.rb
|
|
164
|
+
- lib/mammoth/route_filter.rb
|
|
164
165
|
- lib/mammoth/sources/postgres.rb
|
|
165
166
|
- lib/mammoth/sql/__bootstrap__.sql
|
|
166
167
|
- lib/mammoth/sqlite_store.rb
|