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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6333539be1c721f39698edbb27f8285e5b26755b3838dc3f342a198f93b4ff56
4
- data.tar.gz: 1c825fea3c20668a1095e69a1931abd01f95bd8eb247365ce1b702e99bb40360
3
+ metadata.gz: fc13868a4aa6d845e63c655af36b9bbb69604be8bfb5b7ab6beda7d1a765c145
4
+ data.tar.gz: 9a3c9514953f68db2d5287903e29980ffb147460691817f5de1a19a8123f1727
5
5
  SHA512:
6
- metadata.gz: d2470011cc7d16c9d76afde146d529dc7c56963ade4819e77fa75a81c43b7fc535ba57d7e6291eeea94547365a096e38db9182f0c7af01cc011f05cff81ef5a6
7
- data.tar.gz: b06a8a8d35914d9d123fc7843e3c1318624ec5250c3b89656537680ae0bba9b22dfdf8b4bcb278769e3d49d4fabea4571d107119b19fe824b274e8345e45e0e4
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
- ## OSS MVP
42
+ ## v1.0 Release Scope
43
43
 
44
- Mammoth OSS includes:
44
+ Mammoth 1.0 includes:
45
45
 
46
- - CLI foundation
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, checkpoint, and DLQ handling
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).
@@ -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 so PostgreSQL knows the
131
- # consumer is alive and can advance slot state safely.
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 weaker
149
- # transaction-level semantics.
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 the safer
153
- # default because checkpointing can advance only after the whole transaction
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
@@ -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 the replication slot if it does not exist. Convenient for development; DBA-managed slots are recommended for production."
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",
@@ -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.start do |work|
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(delivery_worker:, delivery_unit: delivery_unit),
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
- def initialize(delivery_worker:, delivery_unit: :event)
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, checkpoint, and dead-letter handling.
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, advance the checkpoint
8
- # after success, and persist the failed event to the dead letter queue after
9
- # retry exhaustion.
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] checkpoint persistence
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] checkpoint persistence
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, checkpoint, and DLQ handling.
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, checkpoint, and DLQ handling.
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(payload, idempotency_key:, reason:)
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