mammoth 0.3.0 → 0.5.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 +15 -1
- data/README.md +6 -2
- data/config/mammoth.example.yml +53 -10
- data/config/mammoth.schema.json +117 -2
- data/lib/mammoth/application.rb +17 -1
- data/lib/mammoth/cli.rb +11 -1
- data/lib/mammoth/configuration.rb +12 -0
- data/lib/mammoth/dead_letter_commands.rb +17 -1
- data/lib/mammoth/fanout_delivery_worker.rb +73 -0
- data/lib/mammoth/observability_server.rb +85 -0
- data/lib/mammoth/observability_snapshot.rb +129 -0
- data/lib/mammoth/status.rb +8 -1
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth/webhook_sink.rb +24 -15
- 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: 8f299dc783af6453331a635200b8fe3ddf03159b156a3a0cec6cc6377ede1c34
|
|
4
|
+
data.tar.gz: f56eabc963912066610c22a98af55af9a6db192f40869dfb1d5743c8f1eff98e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3167effc0333430719118b2ad3f8d8b53841c8c584b1e45949e256411e1a275eb56b71bd023d4d3778e31ec92f179c338d5814220f11c251f447aad7eee0233
|
|
7
|
+
data.tar.gz: d6db35472dfb2847e00f2158fd8677f26da5cb3c19d91e002566d598d9ecd8d67681e35dc2310ffd7fd41dcc0aec5535e822fd52f55f3865f9f65cc267633383
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
## 0.
|
|
5
|
+
## 0.5.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added multi-destination webhook fanout through `destinations`, with independent per-destination retry, delivered-ledger, and dead-letter state.
|
|
10
|
+
- Added targeted fanout dead-letter replay so replay sends failed work back only to the original destination.
|
|
11
|
+
- Added Helm rendering for fanout destinations, including secret-backed environment variables for Authorization headers and HMAC signing secrets.
|
|
12
|
+
|
|
13
|
+
## 0.4.0
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Added optional observability support with `/healthz`, `/readyz`, and `/metrics` endpoints, plus CLI startup, configuration, and documentation coverage.
|
|
18
|
+
|
|
19
|
+
## 0.4.0
|
|
6
20
|
|
|
7
21
|
### Added
|
|
8
22
|
|
data/README.md
CHANGED
|
@@ -17,7 +17,7 @@ CDC::Core::TransactionEnvelope
|
|
|
17
17
|
↓
|
|
18
18
|
Mammoth
|
|
19
19
|
↓
|
|
20
|
-
Webhook
|
|
20
|
+
Webhook fanout
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
🦣 Mammoth is intentionally boring infrastructure. It uses YAML configuration,
|
|
@@ -46,6 +46,7 @@ https://kanutocd.github.io/mammoth/Mammoth.html
|
|
|
46
46
|
- checkpoint persistence
|
|
47
47
|
- dead letter persistence
|
|
48
48
|
- webhook delivery sink
|
|
49
|
+
- webhook fanout to multiple destinations
|
|
49
50
|
- delivery worker with retry, checkpoint, and DLQ handling
|
|
50
51
|
- dead-letter inspection and replay commands
|
|
51
52
|
- CDC-core event serialization boundary
|
|
@@ -53,10 +54,11 @@ https://kanutocd.github.io/mammoth/Mammoth.html
|
|
|
53
54
|
- Docker image support
|
|
54
55
|
- public Helm chart support
|
|
55
56
|
- unit and e2e test tasks
|
|
57
|
+
- health and metrics endpoints
|
|
56
58
|
|
|
57
59
|
## Boundary
|
|
58
60
|
|
|
59
|
-
Mammoth begins at CDC-core work items and ends at webhook delivery.
|
|
61
|
+
Mammoth begins at CDC-core work items and ends at webhook fanout delivery.
|
|
60
62
|
|
|
61
63
|
Mammoth does not own pgoutput protocol parsing, value decoding, source
|
|
62
64
|
normalization, ordering policy, or runtime execution. Those belong to the
|
|
@@ -84,6 +86,7 @@ bundle exec ./exe/mammoth validate config/mammoth.example.yml
|
|
|
84
86
|
bundle exec ./exe/mammoth bootstrap config/mammoth.example.yml
|
|
85
87
|
bundle exec ./exe/mammoth status config/mammoth.example.yml
|
|
86
88
|
bundle exec ./exe/mammoth start config/mammoth.example.yml
|
|
89
|
+
bundle exec ./exe/mammoth observability config/mammoth.example.yml
|
|
87
90
|
```
|
|
88
91
|
|
|
89
92
|
Deliver a single normalized event JSON file through Mammoth's delivery path:
|
|
@@ -101,6 +104,7 @@ Mammoth stores operational memory in SQLite:
|
|
|
101
104
|
- `schema_migrations`
|
|
102
105
|
- `checkpoints`
|
|
103
106
|
- `dead_letters`
|
|
107
|
+
- `delivered_envelopes`
|
|
104
108
|
|
|
105
109
|
## Performance
|
|
106
110
|
|
data/config/mammoth.example.yml
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
# Mammoth example configuration.
|
|
4
4
|
#
|
|
5
|
-
# This file is intentionally verbose. It is
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# This file is intentionally verbose. It is the primary operator-facing
|
|
6
|
+
# reference for local development, Kubernetes values, and production
|
|
7
|
+
# deployments. Update URLs, environment variables, and PostgreSQL settings
|
|
8
|
+
# before using it as a runtime config.
|
|
8
9
|
#
|
|
9
10
|
# Architecture reminder:
|
|
10
11
|
#
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
mammoth:
|
|
25
26
|
# Human-readable Mammoth instance name.
|
|
26
27
|
#
|
|
27
|
-
# Used in logs, status output, metrics, and
|
|
28
|
+
# Used in logs, status output, metrics, and operational dashboards.
|
|
28
29
|
# Keep this stable per environment.
|
|
29
30
|
#
|
|
30
31
|
# Examples:
|
|
@@ -177,8 +178,7 @@ runtime:
|
|
|
177
178
|
#
|
|
178
179
|
# concurrent:
|
|
179
180
|
# Use CDC::Concurrent::ProcessorPool for I/O-heavy delivery workloads such as
|
|
180
|
-
# webhook HTTP calls, retry sleeps/backoff, and
|
|
181
|
-
# fanout.
|
|
181
|
+
# webhook HTTP calls, retry sleeps/backoff, and multi-destination fanout.
|
|
182
182
|
adapter: concurrent
|
|
183
183
|
|
|
184
184
|
# Number of downstream delivery workers.
|
|
@@ -193,6 +193,12 @@ runtime:
|
|
|
193
193
|
# webhook-heavy production: 10-50, after soak testing
|
|
194
194
|
concurrency: 1
|
|
195
195
|
|
|
196
|
+
# Number of work units submitted to the runtime together.
|
|
197
|
+
#
|
|
198
|
+
# Keep this at 1 unless you have validated downstream ordering, retry, and
|
|
199
|
+
# receiver idempotency behavior under load.
|
|
200
|
+
batch_size: 1
|
|
201
|
+
|
|
196
202
|
# Preserve configured ordering while using the runtime.
|
|
197
203
|
#
|
|
198
204
|
# true:
|
|
@@ -206,11 +212,17 @@ runtime:
|
|
|
206
212
|
# Optional runtime timeout in seconds.
|
|
207
213
|
#
|
|
208
214
|
# Leave blank to let destination-specific timeouts control delivery behavior.
|
|
209
|
-
#
|
|
215
|
+
# Use this to bound end-to-end processor execution.
|
|
210
216
|
timeout_seconds:
|
|
211
217
|
|
|
218
|
+
# Single webhook destination shorthand.
|
|
219
|
+
#
|
|
220
|
+
# Use this for one destination. Use destinations below instead when Mammoth
|
|
221
|
+
# should fan out to multiple webhook receivers. Do not configure both unless you
|
|
222
|
+
# intentionally want destinations to replace this shorthand.
|
|
212
223
|
webhook:
|
|
213
|
-
# Destination name used in logs, dead-letter records,
|
|
224
|
+
# Destination name used in logs, dead-letter records, delivered ledgers, and
|
|
225
|
+
# metrics.
|
|
214
226
|
name: primary_webhook
|
|
215
227
|
|
|
216
228
|
# Destination URL for delivered CDC payloads.
|
|
@@ -230,6 +242,7 @@ webhook:
|
|
|
230
242
|
X-Mammoth-Source: local_mammoth
|
|
231
243
|
|
|
232
244
|
# Optional HTTP headers whose values are read from environment variables.
|
|
245
|
+
# In Kubernetes, set these variables from Secrets.
|
|
233
246
|
#
|
|
234
247
|
# Example:
|
|
235
248
|
# header_env:
|
|
@@ -244,6 +257,7 @@ webhook:
|
|
|
244
257
|
#
|
|
245
258
|
# using the secret from secret_env, then sends the timestamp and signature in
|
|
246
259
|
# configurable headers. The signature value is prefixed with "sha256=".
|
|
260
|
+
# In Kubernetes, set the secret_env variable from a Secret.
|
|
247
261
|
#
|
|
248
262
|
# signing:
|
|
249
263
|
# algorithm: hmac_sha256
|
|
@@ -251,6 +265,28 @@ webhook:
|
|
|
251
265
|
# signature_header: X-Mammoth-Signature
|
|
252
266
|
# timestamp_header: X-Mammoth-Timestamp
|
|
253
267
|
|
|
268
|
+
# Optional fanout configuration.
|
|
269
|
+
#
|
|
270
|
+
# Use destinations instead of webhook when one PostgreSQL replication stream
|
|
271
|
+
# should deliver the same CDC work item to multiple webhook receivers. Each
|
|
272
|
+
# destination gets independent retry, delivered-ledger, and dead-letter state.
|
|
273
|
+
# The destinations list replaces the webhook shorthand above.
|
|
274
|
+
#
|
|
275
|
+
# destinations:
|
|
276
|
+
# - name: primary_webhook
|
|
277
|
+
# type: webhook
|
|
278
|
+
# url: https://example.com/webhooks/postgres
|
|
279
|
+
# timeout_seconds: 5
|
|
280
|
+
# header_env:
|
|
281
|
+
# Authorization: MAMMOTH_PRIMARY_WEBHOOK_AUTHORIZATION
|
|
282
|
+
# signing:
|
|
283
|
+
# algorithm: hmac_sha256
|
|
284
|
+
# secret_env: MAMMOTH_PRIMARY_WEBHOOK_SIGNING_SECRET
|
|
285
|
+
# - name: audit_webhook
|
|
286
|
+
# type: webhook
|
|
287
|
+
# url: https://audit.example.com/cdc
|
|
288
|
+
# timeout_seconds: 5
|
|
289
|
+
|
|
254
290
|
retry:
|
|
255
291
|
# Maximum delivery attempts before the event/envelope is moved to the dead
|
|
256
292
|
# letter store.
|
|
@@ -270,10 +306,17 @@ retry:
|
|
|
270
306
|
sqlite:
|
|
271
307
|
# Local operational database path.
|
|
272
308
|
#
|
|
273
|
-
# Mammoth stores checkpoints,
|
|
274
|
-
# In Kubernetes, back this path with a PersistentVolumeClaim.
|
|
309
|
+
# Mammoth stores checkpoints, delivered-envelope ledgers, and dead-letter
|
|
310
|
+
# records here. In Kubernetes, back this path with a PersistentVolumeClaim.
|
|
275
311
|
path: data/mammoth.db
|
|
276
312
|
|
|
313
|
+
observability:
|
|
314
|
+
# Bind host for the optional health, readiness, and metrics server.
|
|
315
|
+
host: 0.0.0.0
|
|
316
|
+
|
|
317
|
+
# HTTP port for /healthz, /readyz, and /metrics.
|
|
318
|
+
port: 9393
|
|
319
|
+
|
|
277
320
|
logging:
|
|
278
321
|
# Log verbosity.
|
|
279
322
|
#
|
data/config/mammoth.schema.json
CHANGED
|
@@ -8,11 +8,22 @@
|
|
|
8
8
|
"mammoth",
|
|
9
9
|
"postgres",
|
|
10
10
|
"replication",
|
|
11
|
-
"webhook",
|
|
12
11
|
"retry",
|
|
13
12
|
"sqlite",
|
|
14
13
|
"logging"
|
|
15
14
|
],
|
|
15
|
+
"anyOf": [
|
|
16
|
+
{
|
|
17
|
+
"required": [
|
|
18
|
+
"webhook"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"required": [
|
|
23
|
+
"destinations"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
16
27
|
"properties": {
|
|
17
28
|
"mammoth": {
|
|
18
29
|
"type": "object",
|
|
@@ -187,7 +198,93 @@
|
|
|
187
198
|
"description": "Optional HMAC-SHA256 request signing for webhook receivers."
|
|
188
199
|
}
|
|
189
200
|
},
|
|
190
|
-
"description": "HTTP webhook destination settings."
|
|
201
|
+
"description": "HTTP webhook destination settings. This is shorthand for a single webhook destination; use destinations for fanout."
|
|
202
|
+
},
|
|
203
|
+
"destinations": {
|
|
204
|
+
"type": "array",
|
|
205
|
+
"minItems": 1,
|
|
206
|
+
"items": {
|
|
207
|
+
"type": "object",
|
|
208
|
+
"additionalProperties": false,
|
|
209
|
+
"required": [
|
|
210
|
+
"name",
|
|
211
|
+
"type",
|
|
212
|
+
"url",
|
|
213
|
+
"timeout_seconds"
|
|
214
|
+
],
|
|
215
|
+
"properties": {
|
|
216
|
+
"name": {
|
|
217
|
+
"type": "string",
|
|
218
|
+
"minLength": 1,
|
|
219
|
+
"description": "Destination name used in delivered ledgers, dead-letter records, logs, and metrics."
|
|
220
|
+
},
|
|
221
|
+
"type": {
|
|
222
|
+
"type": "string",
|
|
223
|
+
"enum": [
|
|
224
|
+
"webhook"
|
|
225
|
+
],
|
|
226
|
+
"description": "Destination adapter type. Mammoth OSS 0.5.0 supports webhook destinations."
|
|
227
|
+
},
|
|
228
|
+
"url": {
|
|
229
|
+
"type": "string",
|
|
230
|
+
"format": "uri",
|
|
231
|
+
"description": "Webhook URL for delivered CDC payloads. Transaction delivery sends a TransactionEnvelope payload; event delivery sends a single event payload."
|
|
232
|
+
},
|
|
233
|
+
"timeout_seconds": {
|
|
234
|
+
"type": "integer",
|
|
235
|
+
"minimum": 1,
|
|
236
|
+
"description": "HTTP timeout in seconds for this destination's delivery attempts."
|
|
237
|
+
},
|
|
238
|
+
"headers": {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"additionalProperties": {
|
|
241
|
+
"type": "string"
|
|
242
|
+
},
|
|
243
|
+
"description": "Static HTTP headers added to every request for this destination. Do not put secrets here; use header_env for secret-backed values."
|
|
244
|
+
},
|
|
245
|
+
"header_env": {
|
|
246
|
+
"type": "object",
|
|
247
|
+
"additionalProperties": {
|
|
248
|
+
"type": "string",
|
|
249
|
+
"minLength": 1
|
|
250
|
+
},
|
|
251
|
+
"description": "HTTP headers whose values are read from environment variables. Keys are header names; values are environment variable names."
|
|
252
|
+
},
|
|
253
|
+
"signing": {
|
|
254
|
+
"type": "object",
|
|
255
|
+
"additionalProperties": false,
|
|
256
|
+
"required": [
|
|
257
|
+
"secret_env"
|
|
258
|
+
],
|
|
259
|
+
"properties": {
|
|
260
|
+
"algorithm": {
|
|
261
|
+
"type": "string",
|
|
262
|
+
"enum": [
|
|
263
|
+
"hmac_sha256"
|
|
264
|
+
],
|
|
265
|
+
"description": "Webhook signing algorithm. Mammoth signs '<timestamp>.<body>' using HMAC-SHA256."
|
|
266
|
+
},
|
|
267
|
+
"secret_env": {
|
|
268
|
+
"type": "string",
|
|
269
|
+
"minLength": 1,
|
|
270
|
+
"description": "Environment variable containing the webhook signing secret."
|
|
271
|
+
},
|
|
272
|
+
"signature_header": {
|
|
273
|
+
"type": "string",
|
|
274
|
+
"minLength": 1,
|
|
275
|
+
"description": "Header name used for the generated signature. Defaults to X-Mammoth-Signature."
|
|
276
|
+
},
|
|
277
|
+
"timestamp_header": {
|
|
278
|
+
"type": "string",
|
|
279
|
+
"minLength": 1,
|
|
280
|
+
"description": "Header name used for the signing timestamp. Defaults to X-Mammoth-Timestamp."
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"description": "Optional HMAC-SHA256 request signing for this webhook destination."
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
"description": "Fanout destination list. Each CDC work item is delivered independently to each configured destination."
|
|
191
288
|
},
|
|
192
289
|
"retry": {
|
|
193
290
|
"type": "object",
|
|
@@ -319,6 +416,24 @@
|
|
|
319
416
|
}
|
|
320
417
|
},
|
|
321
418
|
"description": "Downstream delivery runtime settings. Runtime concurrency affects webhook/destination delivery only, not PostgreSQL replication stream count."
|
|
419
|
+
},
|
|
420
|
+
"observability": {
|
|
421
|
+
"type": "object",
|
|
422
|
+
"additionalProperties": false,
|
|
423
|
+
"properties": {
|
|
424
|
+
"host": {
|
|
425
|
+
"type": "string",
|
|
426
|
+
"minLength": 1,
|
|
427
|
+
"description": "Bind host for the optional Mammoth observability HTTP server exposing /healthz, /readyz, and /metrics."
|
|
428
|
+
},
|
|
429
|
+
"port": {
|
|
430
|
+
"type": "integer",
|
|
431
|
+
"minimum": 1,
|
|
432
|
+
"maximum": 65535,
|
|
433
|
+
"description": "TCP port for the optional Mammoth observability HTTP server."
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
"description": "Optional health, readiness, and metrics endpoint settings."
|
|
322
437
|
}
|
|
323
438
|
},
|
|
324
439
|
"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/application.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Mammoth
|
|
|
21
21
|
@sqlite_store = SQLiteStore.connect(config.dig("sqlite", "path")).bootstrap!
|
|
22
22
|
@checkpoint_store = CheckpointStore.new(sqlite_store)
|
|
23
23
|
@consumer = ReplicationConsumer.new(source: source || build_source, delivery_unit: delivery_unit)
|
|
24
|
-
@delivery_worker = build_delivery_worker(sink: sink
|
|
24
|
+
@delivery_worker = sink ? build_delivery_worker(sink: sink, sleeper: sleeper) : build_configured_delivery_worker(sleeper:)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# Start the application runtime and deliver consumed CDC work.
|
|
@@ -97,6 +97,22 @@ module Mammoth
|
|
|
97
97
|
)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
def build_configured_delivery_worker(sleeper:)
|
|
101
|
+
workers = destination_sinks.map { |sink| build_delivery_worker(sink:, sleeper:) }
|
|
102
|
+
return workers.fetch(0) if workers.one?
|
|
103
|
+
|
|
104
|
+
FanoutDeliveryWorker.new(workers)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def destination_sinks
|
|
108
|
+
destinations = config.data["destinations"]
|
|
109
|
+
return [WebhookSink.from_config(config)] unless destinations
|
|
110
|
+
|
|
111
|
+
destinations.map.with_index(1) do |destination, index|
|
|
112
|
+
WebhookSink.from_destination_config(destination, label: "destinations[#{index - 1}]")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
100
116
|
def transaction_delivery?
|
|
101
117
|
delivery_unit == :transaction
|
|
102
118
|
end
|
data/lib/mammoth/cli.rb
CHANGED
|
@@ -26,7 +26,8 @@ module Mammoth
|
|
|
26
26
|
" mammoth deliver-sample CONFIG EVENT_JSON",
|
|
27
27
|
" mammoth dead-letters list CONFIG [--status STATUS] [--limit N]",
|
|
28
28
|
" mammoth dead-letters show CONFIG ID",
|
|
29
|
-
" mammoth dead-letters replay CONFIG [ID ...]"
|
|
29
|
+
" mammoth dead-letters replay CONFIG [ID ...]",
|
|
30
|
+
" mammoth observability CONFIG"
|
|
30
31
|
].join("\n")
|
|
31
32
|
|
|
32
33
|
# Run the CLI.
|
|
@@ -56,6 +57,7 @@ module Mammoth
|
|
|
56
57
|
when "start" then start
|
|
57
58
|
when "deliver-sample" then deliver_sample
|
|
58
59
|
when "dead-letters" then dead_letters
|
|
60
|
+
when "observability" then observability
|
|
59
61
|
else
|
|
60
62
|
warn USAGE
|
|
61
63
|
1
|
|
@@ -130,6 +132,14 @@ module Mammoth
|
|
|
130
132
|
DeadLetterCommands.call(argv)
|
|
131
133
|
end
|
|
132
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
|
+
|
|
133
143
|
def load_config
|
|
134
144
|
raise ConfigurationError, "configuration path required\n#{USAGE}" unless config_path
|
|
135
145
|
|
|
@@ -44,6 +44,7 @@ module Mammoth
|
|
|
44
44
|
raise ConfigurationError, "configuration must be a YAML mapping" unless data.is_a?(Hash)
|
|
45
45
|
|
|
46
46
|
validate_schema!
|
|
47
|
+
validate_destination_names!
|
|
47
48
|
self
|
|
48
49
|
rescue Psych::SyntaxError => e
|
|
49
50
|
raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
|
|
@@ -73,5 +74,16 @@ module Mammoth
|
|
|
73
74
|
def schema_error_message(errors)
|
|
74
75
|
(["configuration failed schema validation:"] + errors.map { |error| "- #{error}" }).join("\n")
|
|
75
76
|
end
|
|
77
|
+
|
|
78
|
+
def validate_destination_names!
|
|
79
|
+
destinations = data["destinations"]
|
|
80
|
+
return unless destinations
|
|
81
|
+
|
|
82
|
+
names = destinations.map { |destination| destination["name"] }
|
|
83
|
+
duplicates = names.tally.select { |_name, count| count > 1 }.keys
|
|
84
|
+
return if duplicates.empty?
|
|
85
|
+
|
|
86
|
+
raise ConfigurationError, "destination names must be unique: #{duplicates.join(", ")}"
|
|
87
|
+
end
|
|
76
88
|
end
|
|
77
89
|
end
|
|
@@ -163,7 +163,23 @@ module Mammoth
|
|
|
163
163
|
|
|
164
164
|
def replay_row(row)
|
|
165
165
|
payload = JSON.parse(row.fetch("payload_json"))
|
|
166
|
-
transaction_payload?(payload)
|
|
166
|
+
if transaction_payload?(payload)
|
|
167
|
+
replay_transaction(row.fetch("destination_name"), transaction_envelope(payload))
|
|
168
|
+
else
|
|
169
|
+
replay_event(row.fetch("destination_name"), payload)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def replay_event(destination_name, payload)
|
|
174
|
+
return worker.deliver_to(destination_name, payload) if worker.respond_to?(:deliver_to)
|
|
175
|
+
|
|
176
|
+
worker.deliver(payload)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def replay_transaction(destination_name, envelope)
|
|
180
|
+
return worker.deliver_transaction_to(destination_name, envelope) if worker.respond_to?(:deliver_transaction_to)
|
|
181
|
+
|
|
182
|
+
worker.deliver_transaction(envelope)
|
|
167
183
|
end
|
|
168
184
|
|
|
169
185
|
def transaction_payload?(payload)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Delivers one CDC work item to multiple destination-specific workers.
|
|
5
|
+
class FanoutDeliveryWorker
|
|
6
|
+
attr_reader :delivery_workers
|
|
7
|
+
|
|
8
|
+
# @param delivery_workers [Array<Mammoth::DeliveryWorker>] destination workers
|
|
9
|
+
def initialize(delivery_workers)
|
|
10
|
+
raise ConfigurationError, "at least one destination is required" if delivery_workers.empty?
|
|
11
|
+
|
|
12
|
+
@delivery_workers = delivery_workers
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Deliver an event to every configured destination.
|
|
16
|
+
#
|
|
17
|
+
# @param event [Hash, #to_h] normalized event
|
|
18
|
+
# @return [Hash] aggregate fanout summary
|
|
19
|
+
def deliver(event)
|
|
20
|
+
fanout(:deliver, event)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Deliver a transaction envelope to every configured destination.
|
|
24
|
+
#
|
|
25
|
+
# @param envelope [#events, #transaction_id] CDC transaction envelope
|
|
26
|
+
# @return [Hash] aggregate fanout summary
|
|
27
|
+
def deliver_transaction(envelope)
|
|
28
|
+
fanout(:deliver_transaction, envelope)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Deliver an event to one configured destination.
|
|
32
|
+
#
|
|
33
|
+
# @param destination_name [String] destination name
|
|
34
|
+
# @param event [Hash, #to_h] normalized event
|
|
35
|
+
# @return [Hash] destination delivery summary
|
|
36
|
+
def deliver_to(destination_name, event)
|
|
37
|
+
worker_for(destination_name).deliver(event)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Deliver a transaction envelope to one configured destination.
|
|
41
|
+
#
|
|
42
|
+
# @param destination_name [String] destination name
|
|
43
|
+
# @param envelope [#events, #transaction_id] CDC transaction envelope
|
|
44
|
+
# @return [Hash] destination delivery summary
|
|
45
|
+
def deliver_transaction_to(destination_name, envelope)
|
|
46
|
+
worker_for(destination_name).deliver_transaction(envelope)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def fanout(delivery_method, work)
|
|
52
|
+
results = delivery_workers.map { |worker| worker.public_send(delivery_method, work) }
|
|
53
|
+
{
|
|
54
|
+
status: aggregate_status(results),
|
|
55
|
+
destinations: results,
|
|
56
|
+
delivered: results.count { |result| result.fetch(:status) == "delivered" },
|
|
57
|
+
skipped: results.count { |result| result.fetch(:status) == "skipped" },
|
|
58
|
+
dead_lettered: results.count { |result| result.fetch(:status) == "dead_lettered" }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def aggregate_status(results)
|
|
63
|
+
return "fanout_delivered" if results.all? { |result| %w[delivered skipped].include?(result.fetch(:status)) }
|
|
64
|
+
|
|
65
|
+
"fanout_partial"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def worker_for(destination_name)
|
|
69
|
+
delivery_workers.find { |worker| worker.send(:destination_name) == destination_name } ||
|
|
70
|
+
raise(ConfigurationError, "destination not configured: #{destination_name}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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/status.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Mammoth
|
|
|
30
30
|
puts "Replication publications: #{Array(config.dig("replication", "publications")).join(", ")}"
|
|
31
31
|
puts "Runtime: not started"
|
|
32
32
|
puts "SQLite: #{sqlite_path}"
|
|
33
|
-
puts "
|
|
33
|
+
puts "Destinations: #{destination_names.join(", ")}"
|
|
34
34
|
print_store_state if sqlite_store
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -40,6 +40,13 @@ module Mammoth
|
|
|
40
40
|
config.dig("sqlite", "path")
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
def destination_names
|
|
44
|
+
destinations = config.data["destinations"]
|
|
45
|
+
return destinations.map { |destination| destination.fetch("name") } if destinations
|
|
46
|
+
|
|
47
|
+
[config.dig("webhook", "name")]
|
|
48
|
+
end
|
|
49
|
+
|
|
43
50
|
def print_store_state
|
|
44
51
|
store = sqlite_store.bootstrap!
|
|
45
52
|
puts "Tables: #{store.tables.join(", ")}"
|
data/lib/mammoth/version.rb
CHANGED
data/lib/mammoth/webhook_sink.rb
CHANGED
|
@@ -39,43 +39,52 @@ module Mammoth
|
|
|
39
39
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
40
40
|
# @return [Mammoth::WebhookSink]
|
|
41
41
|
def from_config(config)
|
|
42
|
+
from_destination_config(config.data["webhook"], label: "webhook")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Build a sink from one destination configuration entry.
|
|
46
|
+
#
|
|
47
|
+
# @param destination [Hash] destination configuration
|
|
48
|
+
# @param label [String] configuration path used in error messages
|
|
49
|
+
# @return [Mammoth::WebhookSink]
|
|
50
|
+
def from_destination_config(destination, label: "destination")
|
|
42
51
|
new(
|
|
43
|
-
name:
|
|
44
|
-
url:
|
|
45
|
-
timeout_seconds:
|
|
46
|
-
headers: configured_headers(
|
|
47
|
-
signing: configured_signing(
|
|
52
|
+
name: destination.fetch("name"),
|
|
53
|
+
url: destination.fetch("url"),
|
|
54
|
+
timeout_seconds: destination.fetch("timeout_seconds"),
|
|
55
|
+
headers: configured_headers(destination, label: label),
|
|
56
|
+
signing: configured_signing(destination, label: label)
|
|
48
57
|
)
|
|
49
58
|
end
|
|
50
59
|
|
|
51
60
|
private
|
|
52
61
|
|
|
53
|
-
def configured_headers(
|
|
54
|
-
static_headers =
|
|
55
|
-
env_headers =
|
|
62
|
+
def configured_headers(destination, label:)
|
|
63
|
+
static_headers = destination["headers"] || {}
|
|
64
|
+
env_headers = destination["header_env"] || {}
|
|
56
65
|
|
|
57
|
-
static_headers.merge(resolve_env_headers(env_headers))
|
|
66
|
+
static_headers.merge(resolve_env_headers(env_headers, label: label))
|
|
58
67
|
end
|
|
59
68
|
|
|
60
|
-
def resolve_env_headers(env_headers)
|
|
69
|
+
def resolve_env_headers(env_headers, label:)
|
|
61
70
|
env_headers.each_with_object(Hash.new) do |(header, env_name), resolved| # rubocop:disable Style/EmptyLiteral
|
|
62
71
|
resolved[header] = ENV.fetch(env_name) do
|
|
63
|
-
raise ConfigurationError, "
|
|
72
|
+
raise ConfigurationError, "#{label}.header_env.#{header} references missing environment variable #{env_name}"
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
end
|
|
67
76
|
|
|
68
|
-
def configured_signing(
|
|
69
|
-
signing =
|
|
77
|
+
def configured_signing(destination, label:)
|
|
78
|
+
signing = destination["signing"]
|
|
70
79
|
return unless signing
|
|
71
80
|
|
|
72
81
|
algorithm = signing.fetch("algorithm", SIGNING_ALGORITHM)
|
|
73
|
-
raise ConfigurationError, "
|
|
82
|
+
raise ConfigurationError, "#{label}.signing.algorithm must be #{SIGNING_ALGORITHM}" unless algorithm == SIGNING_ALGORITHM
|
|
74
83
|
|
|
75
84
|
secret_env = signing.fetch("secret_env")
|
|
76
85
|
{
|
|
77
86
|
secret: ENV.fetch(secret_env) do
|
|
78
|
-
raise ConfigurationError, "
|
|
87
|
+
raise ConfigurationError, "#{label}.signing.secret_env references missing environment variable #{secret_env}"
|
|
79
88
|
end,
|
|
80
89
|
signature_header: signing.fetch("signature_header", "X-Mammoth-Signature"),
|
|
81
90
|
timestamp_header: signing.fetch("timestamp_header", "X-Mammoth-Timestamp")
|
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"
|
|
@@ -12,6 +14,7 @@ require_relative "mammoth/event_serializer"
|
|
|
12
14
|
require_relative "mammoth/transaction_envelope_serializer"
|
|
13
15
|
require_relative "mammoth/webhook_sink"
|
|
14
16
|
require_relative "mammoth/delivery_worker"
|
|
17
|
+
require_relative "mammoth/fanout_delivery_worker"
|
|
15
18
|
require_relative "mammoth/delivery_processor"
|
|
16
19
|
require_relative "mammoth/concurrent_delivery_runtime"
|
|
17
20
|
require_relative "mammoth/sources/postgres"
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -157,6 +157,9 @@ files:
|
|
|
157
157
|
- lib/mammoth/delivery_worker.rb
|
|
158
158
|
- lib/mammoth/errors.rb
|
|
159
159
|
- lib/mammoth/event_serializer.rb
|
|
160
|
+
- lib/mammoth/fanout_delivery_worker.rb
|
|
161
|
+
- lib/mammoth/observability_server.rb
|
|
162
|
+
- lib/mammoth/observability_snapshot.rb
|
|
160
163
|
- lib/mammoth/replication_consumer.rb
|
|
161
164
|
- lib/mammoth/sources/postgres.rb
|
|
162
165
|
- lib/mammoth/sql/__bootstrap__.sql
|