mammoth 0.9.0 → 1.0.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 +66 -0
- data/README.md +69 -7
- data/config/mammoth.example.yml +19 -9
- data/config/mammoth.schema.json +2 -2
- data/lib/mammoth/application.rb +34 -3
- data/lib/mammoth/delivery_processor.rb +5 -2
- data/lib/mammoth/delivery_progress_coordinator.rb +153 -0
- data/lib/mammoth/delivery_worker.rb +9 -25
- data/lib/mammoth/observability_metrics.rb +3 -1
- data/lib/mammoth/observability_server.rb +5 -3
- data/lib/mammoth/observability_snapshot.rb +63 -10
- data/lib/mammoth/postgres_observability_metrics.rb +80 -0
- data/lib/mammoth/replication_consumer.rb +28 -7
- data/lib/mammoth/sources/postgres.rb +252 -4
- data/lib/mammoth/sources/postgres_publication_inspector.rb +214 -0
- data/lib/mammoth/sources/postgres_slot_health.rb +90 -0
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth.rb +5 -1
- metadata +33 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc13868a4aa6d845e63c655af36b9bbb69604be8bfb5b7ab6beda7d1a765c145
|
|
4
|
+
data.tar.gz: 9a3c9514953f68db2d5287903e29980ffb147460691817f5de1a19a8123f1727
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 552aa45078fa0ed209890bbfe8eb2dc0672234d4e008c8cc0edd954a284e0b40bffafe16a2a8c2df8dd0219aee3eed742ba2a38531cd3d2acd7b13b35bb48b11
|
|
7
|
+
data.tar.gz: 69f509480ecdd46aa50a7879c3dbc1589becce9a651b1697e1f164ea0d6be781edb9f54238940a32553b59956c8cba05cd2b0caaffca57812823cb32e6fa481b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.0.0 - 2026-07-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added PostgreSQL slot readiness diagnostics and Prometheus gauges for
|
|
10
|
+
inspection availability, presence, activity, retained WAL bytes, safe WAL
|
|
11
|
+
size, WAL status, invalidation, inactivity time, and restart/flush LSNs.
|
|
12
|
+
- `/readyz` now fails closed when the configured slot is missing, inactive,
|
|
13
|
+
invalidated, conflicting, WAL-lost, or cannot be inspected.
|
|
14
|
+
- Added startup validation for every configured publication table that publishes
|
|
15
|
+
`UPDATE` or `DELETE`, accepting a usable primary key, selected replica-identity
|
|
16
|
+
index, or `REPLICA IDENTITY FULL` and reporting all invalid tables together.
|
|
17
|
+
- Added catalog-derived, ordered replica-identity mappings for relation OIDs and
|
|
18
|
+
schema-qualified tables.
|
|
19
|
+
- Added a self-verifying live PostgreSQL example for composite, non-`id`
|
|
20
|
+
replica identities across `INSERT`, `UPDATE`, and `DELETE`.
|
|
21
|
+
- Added a live PostgreSQL example that shows fail-closed restart after slot
|
|
22
|
+
invalidation and the explicit operator reconciliation required to resume.
|
|
23
|
+
- Added a PostgreSQL observability example correlating Mammoth readiness and
|
|
24
|
+
Prometheus slot gauges with native replication and publication catalogs.
|
|
25
|
+
- Added a live additive schema-evolution example demonstrating consumer-first
|
|
26
|
+
compatibility, relation-metadata refresh, and the non-delivery of DDL.
|
|
27
|
+
- Added a destination idempotency example proving atomic receiver-side
|
|
28
|
+
duplicate suppression across isolated Mammoth delivery ledgers.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Added PostgreSQL slot preflight before streaming and fail-closed handling for
|
|
33
|
+
missing, active, lost, invalidated, incompatible, and
|
|
34
|
+
checkpoint-unreachable slots.
|
|
35
|
+
- Disabled automatic slot recreation whenever a configured or persisted resume
|
|
36
|
+
LSN requires WAL continuity.
|
|
37
|
+
- Rejected temporary-slot recovery from durable checkpoints.
|
|
38
|
+
- Preserved pgoutput-client's acknowledgement-compatible transport LSN
|
|
39
|
+
separately from normalized CDC-core `commit_lsn` payload context.
|
|
40
|
+
- Checkpoints and PostgreSQL feedback now use the source-owned transport
|
|
41
|
+
watermark, preventing decimal transaction commit values from reaching
|
|
42
|
+
`runner.ack`.
|
|
43
|
+
- Event-mode delivery resolves an envelope's transport position for each exact
|
|
44
|
+
child event without mutating or rebuilding CDC-core work.
|
|
45
|
+
- Wired publication identity mappings into
|
|
46
|
+
`Pgoutput::SourceAdapter::ReplicaIdentityResolver` so composite and non-`id`
|
|
47
|
+
keys are normalized without partial identity data.
|
|
48
|
+
|
|
49
|
+
### Quality
|
|
50
|
+
|
|
51
|
+
- Upgraded pgoutput-client to 0.4.0 and covered slot identity, health,
|
|
52
|
+
retained-WAL reporting, continuity boundaries, and safe first-time creation.
|
|
53
|
+
- Upgraded pgoutput-source-adapter to 0.3.0 and covered catalog-to-resolver
|
|
54
|
+
composition for composite replica identities.
|
|
55
|
+
- Upgraded pgoutput-decoder to 0.2.0 so key-only old tuples preserve their
|
|
56
|
+
exact decoded columns instead of synthesizing absent non-key values.
|
|
57
|
+
- Added end-to-end source/coordinator coverage proving that a transaction with
|
|
58
|
+
decimal `commit_lsn` checkpoints and acknowledges its formatted transport LSN.
|
|
59
|
+
- Added opt-in PostgreSQL 17 integration coverage for transactional `INSERT`,
|
|
60
|
+
`UPDATE`, and `DELETE` with composite identity, replication reconnects,
|
|
61
|
+
concurrent out-of-order completion, contiguous slot acknowledgement, and
|
|
62
|
+
fail-closed slot invalidation; CI and release gates run the suite against a
|
|
63
|
+
logical-replication service.
|
|
64
|
+
- Strengthened boundary coverage for source-owned position resolution and
|
|
65
|
+
publication-catalog inspection, and synchronized PostgreSQL, runtime,
|
|
66
|
+
operational-state, configuration, and transaction-example documentation.
|
|
67
|
+
- Documented PostgreSQL WAL guardrails, slot/catalog monitoring, DDL and
|
|
68
|
+
sequence limitations, destination conflict ownership, upgrade recovery,
|
|
69
|
+
troubleshooting, example caveats, and Kubernetes deployment policy.
|
|
70
|
+
|
|
5
71
|
## 0.9.0
|
|
6
72
|
|
|
7
73
|
### Changed
|
data/README.md
CHANGED
|
@@ -39,11 +39,12 @@ API documentation:
|
|
|
39
39
|
https://kanutocd.github.io/mammoth/Mammoth.html
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
##
|
|
42
|
+
## v1.0 Release Scope
|
|
43
43
|
|
|
44
|
-
Mammoth
|
|
44
|
+
Mammoth 1.0 includes:
|
|
45
45
|
|
|
46
|
-
- CLI
|
|
46
|
+
- operator CLI for validation, bootstrap, status, delivery, observability, and
|
|
47
|
+
dead-letter workflows
|
|
47
48
|
- YAML configuration loading
|
|
48
49
|
- JSON Schema-backed configuration validation
|
|
49
50
|
- SQLite operational memory bootstrap
|
|
@@ -54,19 +55,60 @@ Mammoth OSS includes:
|
|
|
54
55
|
- webhook fanout to multiple destinations
|
|
55
56
|
- fanout route filters by schema, table, and operation
|
|
56
57
|
- per-destination enable/disable and retry policy controls
|
|
57
|
-
- delivery worker with retry, delivered-ledger,
|
|
58
|
+
- delivery worker with retry, delivered-ledger, and DLQ handling
|
|
59
|
+
- contiguous delivery watermark for checkpoint and PostgreSQL acknowledgement
|
|
60
|
+
- source-owned transport LSN preservation independent of payload `commit_lsn`
|
|
61
|
+
- fail-closed PostgreSQL slot and checkpoint continuity preflight
|
|
62
|
+
- fail-closed publication replica-identity preflight for `UPDATE` and `DELETE`
|
|
58
63
|
- dead-letter inspection and filtered replay commands
|
|
59
64
|
- CDC-core event serialization boundary
|
|
60
65
|
- CDC Ecosystem source-adapter integration boundary
|
|
61
66
|
- Docker image support
|
|
62
67
|
- public Helm chart support
|
|
63
68
|
- unit and e2e test tasks
|
|
64
|
-
- health and metrics endpoints
|
|
69
|
+
- health, PostgreSQL slot readiness, and retained-WAL metrics endpoints
|
|
65
70
|
- canonical CDC dispatch counters through a `CDC::Core::Observer`
|
|
66
71
|
- explicit extension registries for state, destination, and runtime adapters
|
|
67
72
|
- node identity and local capability reporting
|
|
68
73
|
- lifecycle hooks, configuration providers, and reusable local command objects
|
|
69
74
|
|
|
75
|
+
## Feature Examples
|
|
76
|
+
|
|
77
|
+
The runnable examples are organized around production behaviors and failure
|
|
78
|
+
modes, not isolated API snippets.
|
|
79
|
+
|
|
80
|
+
| Example | v1 capability demonstrated |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| [`live_postgres_webhook`](examples/live_postgres_webhook) | End-to-end PostgreSQL logical replication into webhook delivery. |
|
|
83
|
+
| [`transaction_webhook`](examples/transaction_webhook) | TransactionEnvelope preservation through the concurrent runtime. |
|
|
84
|
+
| [`webhook_fanout`](examples/webhook_fanout) | Routed multi-destination fanout, environment-backed headers, signing, and independent retry policies. |
|
|
85
|
+
| [`ordering`](examples/ordering) | Ordered and throughput-oriented transaction scheduling. |
|
|
86
|
+
| [`checkpoint_recovery`](examples/checkpoint_recovery) | Durable restart recovery, replay suppression, checkpointing, and acknowledgement. |
|
|
87
|
+
| [`slot_invalidation_recovery`](examples/slot_invalidation_recovery) | Fail-closed slot invalidation and explicit operator reconciliation. |
|
|
88
|
+
| [`composite_replica_identity`](examples/composite_replica_identity) | Composite, non-`id` replica identity across `INSERT`, `UPDATE`, and `DELETE`. |
|
|
89
|
+
| [`postgres_observability`](examples/postgres_observability) | Slot readiness and Prometheus metrics correlated with PostgreSQL catalogs. |
|
|
90
|
+
| [`schema_evolution`](examples/schema_evolution) | Consumer-first additive schema evolution without implying DDL delivery. |
|
|
91
|
+
| [`destination_idempotency`](examples/destination_idempotency) | Atomic destination-side duplicate suppression across isolated relay ledgers. |
|
|
92
|
+
| [`failing_webhook_retry`](examples/failing_webhook_retry) | Retry exhaustion and durable dead-letter persistence. |
|
|
93
|
+
| [`operational_state`](examples/operational_state) | Inspectable checkpoints, delivered ledgers, and dead letters. |
|
|
94
|
+
| [`kubernetes_helm`](examples/kubernetes_helm) | Single-consumer Kubernetes deployment using the public Helm chart. |
|
|
95
|
+
|
|
96
|
+
See [`examples/README.md`](examples/README.md) for the complete index, boundary
|
|
97
|
+
notes, and commands.
|
|
98
|
+
|
|
99
|
+
## v1 Compatibility
|
|
100
|
+
|
|
101
|
+
Mammoth 1.x treats its validated configuration, serialized webhook envelopes,
|
|
102
|
+
documented CLI command behavior, and forward operational-state migrations as
|
|
103
|
+
supported contracts. Compatible minor releases may add optional configuration
|
|
104
|
+
or payload fields, but do not remove or reinterpret existing fields.
|
|
105
|
+
|
|
106
|
+
Human-readable CLI formatting and PostgreSQL-derived row columns are not frozen:
|
|
107
|
+
scripts should rely on documented exit behavior, while receivers must tolerate
|
|
108
|
+
additive fields and coordinate source schema changes. See
|
|
109
|
+
[`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) for the complete promise and
|
|
110
|
+
major-version boundaries.
|
|
111
|
+
|
|
70
112
|
## Boundary
|
|
71
113
|
|
|
72
114
|
Mammoth begins at CDC-core work items and ends at webhook fanout delivery.
|
|
@@ -82,7 +124,9 @@ For the live PostgreSQL stream, `pgoutput-source-adapter` incrementally owns
|
|
|
82
124
|
`Begin`/`Commit` buffering and emits exact `CDC::Core::ChangeEvent` or
|
|
83
125
|
`CDC::Core::TransactionEnvelope` work items. Mammoth only composes the
|
|
84
126
|
transport, parser, decoder, and source adapter and forwards the resulting core
|
|
85
|
-
work to delivery.
|
|
127
|
+
work to delivery. Mammoth's publication preflight supplies ordered,
|
|
128
|
+
catalog-derived replica-identity columns to the adapter, which owns composite
|
|
129
|
+
and non-`id` key extraction.
|
|
86
130
|
|
|
87
131
|
At the downstream boundary, `Mammoth::DeliveryProcessor` implements
|
|
88
132
|
`CDC::Core::Processor` and returns `CDC::Core::ProcessorResult`. Inline and
|
|
@@ -206,7 +250,17 @@ script/test-e2e
|
|
|
206
250
|
```
|
|
207
251
|
|
|
208
252
|
The e2e task uses a real HTTP receiver, real SQLite database, and real
|
|
209
|
-
filesystem paths.
|
|
253
|
+
filesystem paths. Set `MAMMOTH_E2E_POSTGRES_URL` to include the real PostgreSQL
|
|
254
|
+
logical-replication scenarios:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
MAMMOTH_E2E_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:5432/mammoth_e2e \
|
|
258
|
+
bundle exec rake test:e2e
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The PostgreSQL fixture must have `wal_level=logical`; its test role must be able
|
|
262
|
+
to create publications and logical replication slots, terminate replication
|
|
263
|
+
backends, and change `max_slot_wal_keep_size`.
|
|
210
264
|
|
|
211
265
|
## Kubernetes
|
|
212
266
|
|
|
@@ -225,6 +279,14 @@ helm install mammoth charts/mammoth
|
|
|
225
279
|
The chart uses one replica and `Recreate` strategy to respect PostgreSQL's
|
|
226
280
|
logical replication slot constraint: one slot, one active subscriber.
|
|
227
281
|
|
|
282
|
+
Production operators should also monitor retained WAL and slot readiness,
|
|
283
|
+
configure PostgreSQL retention guardrails, and alert on database disk and
|
|
284
|
+
catalog health. DDL and sequence state are not replicated; coordinate schema
|
|
285
|
+
changes with webhook consumers and synchronize sequences externally when
|
|
286
|
+
building a writable database copy. See
|
|
287
|
+
[`docs/POSTGRESQL.md`](docs/POSTGRESQL.md) and
|
|
288
|
+
[`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md).
|
|
289
|
+
|
|
228
290
|
## License
|
|
229
291
|
|
|
230
292
|
Mammoth OSS is licensed under the [MIT License](LICENSE.txt).
|
data/config/mammoth.example.yml
CHANGED
|
@@ -89,6 +89,8 @@ replication:
|
|
|
89
89
|
#
|
|
90
90
|
# Publications define which table changes PostgreSQL sends to Mammoth.
|
|
91
91
|
# Create them explicitly in production so the database boundary is clear.
|
|
92
|
+
# Tables that publish UPDATE or DELETE must have a primary key, an eligible
|
|
93
|
+
# REPLICA IDENTITY USING INDEX, or REPLICA IDENTITY FULL.
|
|
92
94
|
#
|
|
93
95
|
# Example SQL:
|
|
94
96
|
# CREATE PUBLICATION mammoth_publication FOR TABLE users, orders;
|
|
@@ -98,7 +100,9 @@ replication:
|
|
|
98
100
|
# Optional start LSN for advanced recovery or controlled replay.
|
|
99
101
|
#
|
|
100
102
|
# Leave unset for normal operation. Mammoth will use its checkpoint store and
|
|
101
|
-
# the replication slot position.
|
|
103
|
+
# the replication slot position. Persisted PostgreSQL checkpoints use the
|
|
104
|
+
# acknowledgement-compatible transport LSN, not a normalized transaction's
|
|
105
|
+
# decimal commit_lsn.
|
|
102
106
|
#
|
|
103
107
|
# Example:
|
|
104
108
|
# start_lsn: "0/16B6C50"
|
|
@@ -108,10 +112,15 @@ replication:
|
|
|
108
112
|
# Automatically create the replication slot if it does not exist.
|
|
109
113
|
#
|
|
110
114
|
# true:
|
|
111
|
-
# Convenient for development, demos, and first-time bootstrap.
|
|
115
|
+
# Convenient for development, demos, and first-time bootstrap. Mammoth
|
|
116
|
+
# creates a missing slot only when no configured or persisted resume LSN
|
|
117
|
+
# requires continuity.
|
|
112
118
|
#
|
|
113
119
|
# false:
|
|
114
120
|
# Recommended for production when slots are DBA-managed infrastructure.
|
|
121
|
+
#
|
|
122
|
+
# A missing slot is never recreated while a checkpoint exists. Mammoth fails
|
|
123
|
+
# closed because a replacement slot cannot recover the lost WAL interval.
|
|
115
124
|
auto_create_slot: false
|
|
116
125
|
|
|
117
126
|
# Use a temporary replication slot.
|
|
@@ -127,8 +136,9 @@ replication:
|
|
|
127
136
|
|
|
128
137
|
# Standby feedback interval in seconds.
|
|
129
138
|
#
|
|
130
|
-
# Mammoth sends periodic feedback while streaming
|
|
131
|
-
#
|
|
139
|
+
# Mammoth sends periodic feedback while streaming. The feedback position
|
|
140
|
+
# advances only after every earlier delivery has a durable outcome and the
|
|
141
|
+
# same contiguous position has been written to Mammoth's checkpoint store.
|
|
132
142
|
#
|
|
133
143
|
# Lower values:
|
|
134
144
|
# faster liveness/feedback visibility, slightly more network chatter
|
|
@@ -145,13 +155,13 @@ delivery:
|
|
|
145
155
|
# Delivery granularity.
|
|
146
156
|
#
|
|
147
157
|
# event:
|
|
148
|
-
# Deliver each change event independently. This has lower latency but
|
|
149
|
-
# transaction
|
|
158
|
+
# Deliver each change event independently. This has lower latency but does
|
|
159
|
+
# not give consumers one atomic transaction payload. Checkpoint and WAL
|
|
160
|
+
# acknowledgement still wait for every event in the transaction.
|
|
150
161
|
#
|
|
151
162
|
# transaction:
|
|
152
|
-
# Deliver a complete TransactionEnvelope as one payload. This is
|
|
153
|
-
#
|
|
154
|
-
# has been delivered successfully.
|
|
163
|
+
# Deliver a complete TransactionEnvelope as one payload. This is recommended
|
|
164
|
+
# when downstream consumers need transaction-level context.
|
|
155
165
|
#
|
|
156
166
|
# Recommended:
|
|
157
167
|
# transaction
|
data/config/mammoth.schema.json
CHANGED
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
"type": "string",
|
|
142
142
|
"minLength": 1
|
|
143
143
|
},
|
|
144
|
-
"description": "PostgreSQL publications to subscribe to. Publications define which table changes PostgreSQL sends to Mammoth."
|
|
144
|
+
"description": "PostgreSQL publications to subscribe to. Publications define which table changes PostgreSQL sends to Mammoth. Tables publishing UPDATE or DELETE require a usable primary key, selected replica-identity index, or REPLICA IDENTITY FULL."
|
|
145
145
|
},
|
|
146
146
|
"start_lsn": {
|
|
147
147
|
"type": [
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
},
|
|
153
153
|
"auto_create_slot": {
|
|
154
154
|
"type": "boolean",
|
|
155
|
-
"description": "When true, Mammoth creates
|
|
155
|
+
"description": "When true, Mammoth creates a missing replication slot only for a fresh stream with no configured or persisted resume LSN. Mammoth never recreates a missing slot while checkpoint continuity is required."
|
|
156
156
|
},
|
|
157
157
|
"temporary_slot": {
|
|
158
158
|
"type": "boolean",
|
data/lib/mammoth/application.rb
CHANGED
|
@@ -9,8 +9,10 @@ module Mammoth
|
|
|
9
9
|
# composition stays outside this class so the application runtime consumes an
|
|
10
10
|
# injected CDC work source rather than owning upstream CDC source-adapter
|
|
11
11
|
# lifecycle decisions.
|
|
12
|
+
# rubocop:disable Metrics/ClassLength
|
|
12
13
|
class Application
|
|
13
|
-
attr_reader :config, :state_adapter, :consumer, :delivery_worker, :checkpoint_store, :lifecycle_hooks, :observer
|
|
14
|
+
attr_reader :config, :state_adapter, :consumer, :delivery_worker, :checkpoint_store, :lifecycle_hooks, :observer,
|
|
15
|
+
:progress_coordinator
|
|
14
16
|
|
|
15
17
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
16
18
|
# @param source [#each, nil] injectable event source for tests and demos
|
|
@@ -27,6 +29,7 @@ module Mammoth
|
|
|
27
29
|
@state_adapter = state_adapter || build_state_adapter
|
|
28
30
|
@checkpoint_store = @state_adapter.checkpoint_store
|
|
29
31
|
@consumer = ReplicationConsumer.new(source: source || build_source, delivery_unit: delivery_unit)
|
|
32
|
+
@progress_coordinator = build_progress_coordinator
|
|
30
33
|
@delivery_worker = sink ? build_delivery_worker(sink: sink, sleeper: sleeper) : build_configured_delivery_worker(sleeper:)
|
|
31
34
|
end
|
|
32
35
|
|
|
@@ -72,12 +75,14 @@ module Mammoth
|
|
|
72
75
|
def process_consumer(runtime)
|
|
73
76
|
processed = 0
|
|
74
77
|
|
|
75
|
-
consumer.
|
|
78
|
+
consumer.start_with_boundaries do |work, group_end|
|
|
79
|
+
progress_coordinator.register(work, group_end:)
|
|
76
80
|
process_work(runtime, work)
|
|
77
81
|
processed += 1
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
runtime.flush
|
|
85
|
+
progress_coordinator.finalize
|
|
81
86
|
processed
|
|
82
87
|
end
|
|
83
88
|
|
|
@@ -88,7 +93,11 @@ module Mammoth
|
|
|
88
93
|
def build_runtime
|
|
89
94
|
Runtimes::Registry.build(
|
|
90
95
|
runtime_adapter,
|
|
91
|
-
processor: DeliveryProcessor.new(
|
|
96
|
+
processor: DeliveryProcessor.new(
|
|
97
|
+
delivery_worker:,
|
|
98
|
+
delivery_unit: delivery_unit,
|
|
99
|
+
progress_coordinator: progress_coordinator
|
|
100
|
+
),
|
|
92
101
|
concurrency: runtime_concurrency,
|
|
93
102
|
timeout: runtime_timeout,
|
|
94
103
|
preserve_order: runtime_preserve_order?,
|
|
@@ -101,6 +110,27 @@ module Mammoth
|
|
|
101
110
|
Sources::Postgres.new(config, checkpoint_store: checkpoint_store)
|
|
102
111
|
end
|
|
103
112
|
|
|
113
|
+
def build_progress_coordinator
|
|
114
|
+
DeliveryProgressCoordinator.new(
|
|
115
|
+
checkpoint_store: checkpoint_store,
|
|
116
|
+
source_name: config.dig("mammoth", "name"),
|
|
117
|
+
slot_name: config.dig("replication", "slot"),
|
|
118
|
+
publication_name: Array(config.dig("replication", "publications")).join(","),
|
|
119
|
+
acknowledger: source_acknowledger,
|
|
120
|
+
position_resolver: source_position_resolver
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def source_acknowledger
|
|
125
|
+
source = consumer.source
|
|
126
|
+
source.method(:acknowledge) if source.respond_to?(:acknowledge)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def source_position_resolver
|
|
130
|
+
source = consumer.source
|
|
131
|
+
source.method(:progress_position_for) if source.respond_to?(:progress_position_for)
|
|
132
|
+
end
|
|
133
|
+
|
|
104
134
|
def build_delivery_worker(sink:, sleeper:, delivery_policy: {})
|
|
105
135
|
DeliveryWorker.from_config(
|
|
106
136
|
config,
|
|
@@ -176,4 +206,5 @@ module Mammoth
|
|
|
176
206
|
value.nil? || value
|
|
177
207
|
end
|
|
178
208
|
end
|
|
209
|
+
# rubocop:enable Metrics/ClassLength
|
|
179
210
|
end
|
|
@@ -32,14 +32,16 @@ module Mammoth
|
|
|
32
32
|
|
|
33
33
|
concurrent_safe!
|
|
34
34
|
|
|
35
|
-
attr_reader :delivery_worker, :delivery_unit
|
|
35
|
+
attr_reader :delivery_worker, :delivery_unit, :progress_coordinator
|
|
36
36
|
|
|
37
37
|
# @param delivery_worker [Mammoth::DeliveryWorker] relay-aware delivery worker
|
|
38
38
|
# @param delivery_unit [String, Symbol] event or transaction
|
|
39
|
-
|
|
39
|
+
# @param progress_coordinator [Mammoth::DeliveryProgressCoordinator, nil] contiguous progress coordinator
|
|
40
|
+
def initialize(delivery_worker:, delivery_unit: :event, progress_coordinator: nil)
|
|
40
41
|
super()
|
|
41
42
|
@delivery_worker = delivery_worker
|
|
42
43
|
@delivery_unit = delivery_unit.to_sym
|
|
44
|
+
@progress_coordinator = progress_coordinator
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# @return [Boolean] true when this processor instance is safe for concurrent execution.
|
|
@@ -63,6 +65,7 @@ module Mammoth
|
|
|
63
65
|
# @return [CDC::Core::ProcessorResult] normalized processor result
|
|
64
66
|
def process(work)
|
|
65
67
|
summary = deliver(work)
|
|
68
|
+
progress_coordinator&.complete(work)
|
|
66
69
|
build_result(work, summary)
|
|
67
70
|
rescue StandardError => e
|
|
68
71
|
failure_result(work, e, retryable: e.is_a?(DeliveryError))
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Coordinates durable delivery progress across out-of-order workers.
|
|
5
|
+
#
|
|
6
|
+
# Work is registered in source order before dispatch. Completion may arrive
|
|
7
|
+
# in any order, but a checkpoint and upstream acknowledgement advance only
|
|
8
|
+
# when every item in the oldest closed source group has a durable outcome.
|
|
9
|
+
# A source group is normally one PostgreSQL transaction.
|
|
10
|
+
class DeliveryProgressCoordinator
|
|
11
|
+
# Mutable source transaction tracked by the coordinator.
|
|
12
|
+
class Group
|
|
13
|
+
attr_reader :items
|
|
14
|
+
attr_accessor :closed
|
|
15
|
+
|
|
16
|
+
# @param items [Array<DeliveryProgressCoordinator::Entry>] registered work
|
|
17
|
+
# @param closed [Boolean] whether the source group is complete
|
|
18
|
+
def initialize(items:, closed:)
|
|
19
|
+
@items = items
|
|
20
|
+
@closed = closed
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Mutable completion state for one registered work item.
|
|
25
|
+
class Entry
|
|
26
|
+
attr_reader :work, :source_position
|
|
27
|
+
attr_accessor :completed
|
|
28
|
+
|
|
29
|
+
# @param work [Object] registered work
|
|
30
|
+
# @param source_position [String, Integer, nil] upstream position
|
|
31
|
+
# @param completed [Boolean] whether a durable outcome was recorded
|
|
32
|
+
def initialize(work:, source_position:, completed:)
|
|
33
|
+
@work = work
|
|
34
|
+
@source_position = source_position
|
|
35
|
+
@completed = completed
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :checkpoint_store, :source_name, :slot_name, :publication_name
|
|
40
|
+
|
|
41
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] durable checkpoint persistence
|
|
42
|
+
# @param source_name [String] logical source name
|
|
43
|
+
# @param slot_name [String] PostgreSQL replication slot name
|
|
44
|
+
# @param publication_name [String] publication name
|
|
45
|
+
# @param acknowledger [#call, nil] upstream durable-progress acknowledgement
|
|
46
|
+
# @param position_resolver [#call, nil] source-owned durable position resolver
|
|
47
|
+
def initialize(checkpoint_store:, source_name:, slot_name:, publication_name:, acknowledger: nil,
|
|
48
|
+
position_resolver: nil)
|
|
49
|
+
@checkpoint_store = checkpoint_store
|
|
50
|
+
@source_name = source_name
|
|
51
|
+
@slot_name = slot_name
|
|
52
|
+
@publication_name = publication_name
|
|
53
|
+
@acknowledger = acknowledger
|
|
54
|
+
@position_resolver = position_resolver
|
|
55
|
+
# rubocop:disable Layout/LeadingCommentSpace -- Steep inline type syntax requires `#:`.
|
|
56
|
+
@groups = [] #: Array[Group]
|
|
57
|
+
@entries_by_work = {} #: Hash[untyped, Array[Entry]]
|
|
58
|
+
# rubocop:enable Layout/LeadingCommentSpace
|
|
59
|
+
@entries_by_work.compare_by_identity
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Register work before dispatch.
|
|
64
|
+
#
|
|
65
|
+
# @param work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] work item
|
|
66
|
+
# @param group_end [Boolean] whether this is the final item in its source group
|
|
67
|
+
# @return [void]
|
|
68
|
+
def register(work, group_end:)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
group = current_group
|
|
71
|
+
entry = Entry.new(work: work, source_position: source_position(work), completed: false)
|
|
72
|
+
group.items << entry
|
|
73
|
+
(@entries_by_work[work] ||= []) << entry # steep:ignore
|
|
74
|
+
group.closed = true if group_end
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Mark a work item durably resolved and advance all newly contiguous groups.
|
|
80
|
+
#
|
|
81
|
+
# @param work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] work item
|
|
82
|
+
# @return [String, nil] latest source position advanced by this call
|
|
83
|
+
def complete(work)
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
entry = pending_entry_for(work)
|
|
86
|
+
raise ReplicationError, "delivery progress completed before registration" unless entry
|
|
87
|
+
|
|
88
|
+
entry.completed = true
|
|
89
|
+
advance_contiguous_groups
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Close and advance a final source group after a clean end-of-stream.
|
|
94
|
+
#
|
|
95
|
+
# @return [String, nil] latest source position advanced by this call
|
|
96
|
+
def finalize
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@groups.last.closed = true if @groups.last
|
|
99
|
+
advance_contiguous_groups
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def current_group
|
|
106
|
+
return @groups.last if @groups.last && !@groups.last.closed
|
|
107
|
+
|
|
108
|
+
Group.new(items: [], closed: false).tap { |group| @groups << group }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def pending_entry_for(work)
|
|
112
|
+
@entries_by_work.fetch(work, []).find { |entry| !entry.completed } # steep:ignore
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def advance_contiguous_groups
|
|
116
|
+
advanced_position = nil
|
|
117
|
+
|
|
118
|
+
while (group = @groups.first) && group.closed && group.items.all?(&:completed)
|
|
119
|
+
position = group.items.reverse_each.lazy.map(&:source_position).find { |value| !value.nil? }
|
|
120
|
+
persist_and_acknowledge(position) if position
|
|
121
|
+
advanced_position = position || advanced_position
|
|
122
|
+
remove_group(group)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
advanced_position
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def persist_and_acknowledge(position)
|
|
129
|
+
checkpoint_store.write(
|
|
130
|
+
source_name: source_name,
|
|
131
|
+
slot_name: slot_name,
|
|
132
|
+
publication_name: publication_name,
|
|
133
|
+
last_lsn: position
|
|
134
|
+
)
|
|
135
|
+
@acknowledger&.call(position)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def remove_group(group)
|
|
139
|
+
@groups.shift
|
|
140
|
+
group.items.each do |entry|
|
|
141
|
+
entries = @entries_by_work[entry.work]
|
|
142
|
+
entries.delete(entry)
|
|
143
|
+
@entries_by_work.delete(entry.work) if entries.empty?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def source_position(work)
|
|
148
|
+
return @position_resolver.call(work) if @position_resolver
|
|
149
|
+
|
|
150
|
+
work.commit_lsn if work.respond_to?(:commit_lsn)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mammoth
|
|
4
|
-
# Delivers normalized events with retry,
|
|
4
|
+
# Delivers normalized events with retry, ledger, and dead-letter handling.
|
|
5
5
|
#
|
|
6
6
|
# DeliveryWorker is Mammoth's first reliable delivery unit. It intentionally keeps
|
|
7
|
-
# the delivery contract small: attempt webhook delivery,
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# the delivery contract small: attempt webhook delivery, record idempotent
|
|
8
|
+
# success, and persist exhausted failures to the dead letter queue. Contiguous
|
|
9
|
+
# checkpointing is owned by DeliveryProgressCoordinator.
|
|
10
10
|
class DeliveryWorker
|
|
11
11
|
# Default source name used when an event does not provide one.
|
|
12
12
|
DEFAULT_SOURCE = "postgresql"
|
|
@@ -15,7 +15,7 @@ module Mammoth
|
|
|
15
15
|
:sleeper, :source_name, :slot_name, :publication_name, :route_filter, :enabled
|
|
16
16
|
|
|
17
17
|
# @param sink [#deliver] destination sink
|
|
18
|
-
# @param checkpoint_store [Mammoth::CheckpointStore]
|
|
18
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] retained dependency; shared progress owns writes
|
|
19
19
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
20
20
|
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
|
|
21
21
|
# @param source_name [String] logical source name
|
|
@@ -47,7 +47,7 @@ module Mammoth
|
|
|
47
47
|
#
|
|
48
48
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
49
49
|
# @param sink [#deliver] destination sink
|
|
50
|
-
# @param checkpoint_store [Mammoth::CheckpointStore]
|
|
50
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] retained dependency; shared progress owns writes
|
|
51
51
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
52
52
|
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
|
|
53
53
|
# @param sleeper [#call] sleep strategy
|
|
@@ -70,7 +70,7 @@ module Mammoth
|
|
|
70
70
|
)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# Deliver a transaction envelope with retry,
|
|
73
|
+
# Deliver a transaction envelope with retry, ledger, and DLQ handling.
|
|
74
74
|
#
|
|
75
75
|
# @param envelope [CDC::Core::TransactionEnvelope] CDC transaction envelope
|
|
76
76
|
# @return [Hash] delivery summary
|
|
@@ -78,7 +78,7 @@ module Mammoth
|
|
|
78
78
|
deliver_work(envelope, serializer: TransactionEnvelopeSerializer, delivery_method: :deliver_transaction)
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
# Deliver an event with retry,
|
|
81
|
+
# Deliver an event with retry, ledger, and DLQ handling.
|
|
82
82
|
#
|
|
83
83
|
# @param event [CDC::Core::ChangeEvent] normalized event
|
|
84
84
|
# @return [Hash] delivery summary
|
|
@@ -99,7 +99,6 @@ module Mammoth
|
|
|
99
99
|
return skip_result if skip_result
|
|
100
100
|
|
|
101
101
|
if delivered_envelope_store.delivered?(idempotency_key)
|
|
102
|
-
checkpoint_payload(payload)
|
|
103
102
|
return {
|
|
104
103
|
status: "skipped",
|
|
105
104
|
duplicate: true,
|
|
@@ -121,7 +120,6 @@ module Mammoth
|
|
|
121
120
|
transaction_id: payload["transaction_id"],
|
|
122
121
|
source_position: payload["source_position"]
|
|
123
122
|
)
|
|
124
|
-
checkpoint_payload(payload)
|
|
125
123
|
result.merge(attempts: attempts, idempotency_key: idempotency_key)
|
|
126
124
|
rescue DeliveryError => e
|
|
127
125
|
return dead_letter(work, e, attempts, serializer:) if attempts >= max_attempts
|
|
@@ -132,19 +130,6 @@ module Mammoth
|
|
|
132
130
|
end
|
|
133
131
|
# rubocop:enable Metrics/MethodLength
|
|
134
132
|
|
|
135
|
-
def checkpoint(work, serializer:)
|
|
136
|
-
checkpoint_payload(serializer.call(work))
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def checkpoint_payload(payload)
|
|
140
|
-
checkpoint_store.write(
|
|
141
|
-
source_name: source_name,
|
|
142
|
-
slot_name: slot_name,
|
|
143
|
-
publication_name: publication_name,
|
|
144
|
-
last_lsn: payload["source_position"]
|
|
145
|
-
)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
133
|
def idempotency_key_for(payload:, delivery_unit:)
|
|
149
134
|
[
|
|
150
135
|
source_name,
|
|
@@ -170,8 +155,7 @@ module Mammoth
|
|
|
170
155
|
skipped(payload, idempotency_key:, reason: "route_mismatch") unless route_filter.match_payload?(payload)
|
|
171
156
|
end
|
|
172
157
|
|
|
173
|
-
def skipped(
|
|
174
|
-
checkpoint_payload(payload)
|
|
158
|
+
def skipped(_payload, idempotency_key:, reason:)
|
|
175
159
|
{
|
|
176
160
|
status: "skipped",
|
|
177
161
|
reason: reason,
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Mammoth
|
|
4
4
|
# Prometheus formatting helpers for operational and dispatch metrics.
|
|
5
5
|
module ObservabilityMetrics
|
|
6
|
+
include PostgresObservabilityMetrics
|
|
7
|
+
|
|
6
8
|
# Maps canonical CDC core metric names to Mammoth's Prometheus counters.
|
|
7
9
|
DISPATCH_METRIC_NAMES = {
|
|
8
10
|
CDC::Core::Observer.started_metric_name => "mammoth_dispatch_started_total",
|
|
@@ -14,7 +16,7 @@ module Mammoth
|
|
|
14
16
|
private
|
|
15
17
|
|
|
16
18
|
def metric_headers
|
|
17
|
-
operational_metric_headers + dispatch_metric_headers
|
|
19
|
+
operational_metric_headers + dispatch_metric_headers + postgres_metric_headers
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def operational_metric_headers
|