mammoth 0.9.0 → 1.1.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: 456cf70ebb1cb5a91e7e73a42f847298890e8d56f827ca1175ebe61b1d3bc09a
4
+ data.tar.gz: e726ba8a465f924b45f2f4055a0a6bf076b7b376681ff7db0a72355ddad241e0
5
5
  SHA512:
6
- metadata.gz: d2470011cc7d16c9d76afde146d529dc7c56963ade4819e77fa75a81c43b7fc535ba57d7e6291eeea94547365a096e38db9182f0c7af01cc011f05cff81ef5a6
7
- data.tar.gz: b06a8a8d35914d9d123fc7843e3c1318624ec5250c3b89656537680ae0bba9b22dfdf8b4bcb278769e3d49d4fabea4571d107119b19fe824b274e8345e45e0e4
6
+ metadata.gz: 848144fb545ae6040119e249820a1fd495887b8ff2f7bb3f62d47e2fc91de4869a8ec30eaa56cb4946dd50c2063fc7071a8307ae303b25ddbc40d396c7dd45c8
7
+ data.tar.gz: fabd87ecb39c6158ca730dc9d8926a4157b6e01d8ab48a90b6f305299309d3680a86834351a59fba40e0019cfb1d81de66d34aa9ccabacd45f47a8c3b10577dd
data/CHANGELOG.md CHANGED
@@ -1,6 +1,94 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.1.0 - 2026-07-19
4
+
5
+ ### Added
6
+
7
+ - Added a serializer benchmark comparing explicit and deterministic fallback
8
+ event IDs for event and transaction payload projection.
9
+ - Added `webhooks-quickstart`, a one-command PostgreSQL-to-Mammoth demo with an
10
+ order application; inspectable signed webhook deliveries and retries;
11
+ `INSERT`, `UPDATE`, and `DELETE` events; multi-event payment and reversal
12
+ transactions; adaptation guidance; WAL-retention safeguards; and end-to-end
13
+ verification.
14
+
15
+ ### Fixed
16
+
17
+ - Generate deterministic fallback event and transaction-envelope IDs so retries
18
+ and post-crash redelivery preserve destination idempotency when upstream
19
+ metadata does not provide an event ID.
20
+ - Serialize `CDC::Core::ChangeEvent` column differences when complete old and
21
+ new rows are available, while avoiding false differences from key-only old
22
+ rows.
23
+ - Require pgoutput-source-adapter 0.3.1 and use its stable transaction-local
24
+ event sequence to prevent deterministic ID collisions between otherwise
25
+ identical changes while preserving replay stability.
26
+
27
+ ## 1.0.0 - 2026-07-17
28
+
29
+ ### Added
30
+
31
+ - Added PostgreSQL slot readiness diagnostics and Prometheus gauges for
32
+ inspection availability, presence, activity, retained WAL bytes, safe WAL
33
+ size, WAL status, invalidation, inactivity time, and restart/flush LSNs.
34
+ - `/readyz` now fails closed when the configured slot is missing, inactive,
35
+ invalidated, conflicting, WAL-lost, or cannot be inspected.
36
+ - Added startup validation for every configured publication table that publishes
37
+ `UPDATE` or `DELETE`, accepting a usable primary key, selected replica-identity
38
+ index, or `REPLICA IDENTITY FULL` and reporting all invalid tables together.
39
+ - Added catalog-derived, ordered replica-identity mappings for relation OIDs and
40
+ schema-qualified tables.
41
+ - Added a self-verifying live PostgreSQL example for composite, non-`id`
42
+ replica identities across `INSERT`, `UPDATE`, and `DELETE`.
43
+ - Added a live PostgreSQL example that shows fail-closed restart after slot
44
+ invalidation and the explicit operator reconciliation required to resume.
45
+ - Added a PostgreSQL observability example correlating Mammoth readiness and
46
+ Prometheus slot gauges with native replication and publication catalogs.
47
+ - Added a live additive schema-evolution example demonstrating consumer-first
48
+ compatibility, relation-metadata refresh, and the non-delivery of DDL.
49
+ - Added a destination idempotency example proving atomic receiver-side
50
+ duplicate suppression across isolated Mammoth delivery ledgers.
51
+
52
+ ### Fixed
53
+
54
+ - Added PostgreSQL slot preflight before streaming and fail-closed handling for
55
+ missing, active, lost, invalidated, incompatible, and
56
+ checkpoint-unreachable slots.
57
+ - Disabled automatic slot recreation whenever a configured or persisted resume
58
+ LSN requires WAL continuity.
59
+ - Rejected temporary-slot recovery from durable checkpoints.
60
+ - Preserved pgoutput-client's acknowledgement-compatible transport LSN
61
+ separately from normalized CDC-core `commit_lsn` payload context.
62
+ - Checkpoints and PostgreSQL feedback now use the source-owned transport
63
+ watermark, preventing decimal transaction commit values from reaching
64
+ `runner.ack`.
65
+ - Event-mode delivery resolves an envelope's transport position for each exact
66
+ child event without mutating or rebuilding CDC-core work.
67
+ - Wired publication identity mappings into
68
+ `Pgoutput::SourceAdapter::ReplicaIdentityResolver` so composite and non-`id`
69
+ keys are normalized without partial identity data.
70
+
71
+ ### Quality
72
+
73
+ - Upgraded pgoutput-client to 0.4.0 and covered slot identity, health,
74
+ retained-WAL reporting, continuity boundaries, and safe first-time creation.
75
+ - Upgraded pgoutput-source-adapter to 0.3.0 and covered catalog-to-resolver
76
+ composition for composite replica identities.
77
+ - Upgraded pgoutput-decoder to 0.2.0 so key-only old tuples preserve their
78
+ exact decoded columns instead of synthesizing absent non-key values.
79
+ - Added end-to-end source/coordinator coverage proving that a transaction with
80
+ decimal `commit_lsn` checkpoints and acknowledges its formatted transport LSN.
81
+ - Added opt-in PostgreSQL 17 integration coverage for transactional `INSERT`,
82
+ `UPDATE`, and `DELETE` with composite identity, replication reconnects,
83
+ concurrent out-of-order completion, contiguous slot acknowledgement, and
84
+ fail-closed slot invalidation; CI and release gates run the suite against a
85
+ logical-replication service.
86
+ - Strengthened boundary coverage for source-owned position resolution and
87
+ publication-catalog inspection, and synchronized PostgreSQL, runtime,
88
+ operational-state, configuration, and transaction-example documentation.
89
+ - Documented PostgreSQL WAL guardrails, slot/catalog monitoring, DDL and
90
+ sequence limitations, destination conflict ownership, upgrade recovery,
91
+ troubleshooting, example caveats, and Kubernetes deployment policy.
4
92
 
5
93
  ## 0.9.0
6
94
 
data/README.md CHANGED
@@ -38,12 +38,28 @@ API documentation:
38
38
 
39
39
  https://kanutocd.github.io/mammoth/Mammoth.html
40
40
 
41
+ ## Start Here
41
42
 
42
- ## OSS MVP
43
+ The recommended first-run experience is the
44
+ [`webhooks-quickstart`](webhooks-quickstart) stack. One Compose command starts a
45
+ demo application, logical-replication-enabled PostgreSQL, Mammoth, a signed
46
+ webhook receiver with visible retries, and Mammoth health endpoints:
43
47
 
44
- Mammoth OSS includes:
48
+ ```bash
49
+ cd webhooks-quickstart
50
+ docker compose up --build --wait
51
+ ```
52
+
53
+ Once the flow is visible, follow its
54
+ [`ADAPTING.md`](webhooks-quickstart/ADAPTING.md) guide to connect Mammoth to an
55
+ existing PostgreSQL application.
45
56
 
46
- - CLI foundation
57
+ ## v1 Release Scope
58
+
59
+ Mammoth 1.x includes:
60
+
61
+ - operator CLI for validation, bootstrap, status, delivery, observability, and
62
+ dead-letter workflows
47
63
  - YAML configuration loading
48
64
  - JSON Schema-backed configuration validation
49
65
  - SQLite operational memory bootstrap
@@ -54,19 +70,62 @@ Mammoth OSS includes:
54
70
  - webhook fanout to multiple destinations
55
71
  - fanout route filters by schema, table, and operation
56
72
  - per-destination enable/disable and retry policy controls
57
- - delivery worker with retry, delivered-ledger, checkpoint, and DLQ handling
73
+ - delivery worker with retry, delivered-ledger, and DLQ handling
74
+ - contiguous delivery watermark for checkpoint and PostgreSQL acknowledgement
75
+ - source-owned transport LSN preservation independent of payload `commit_lsn`
76
+ - fail-closed PostgreSQL slot and checkpoint continuity preflight
77
+ - fail-closed publication replica-identity preflight for `UPDATE` and `DELETE`
58
78
  - dead-letter inspection and filtered replay commands
59
79
  - CDC-core event serialization boundary
60
80
  - CDC Ecosystem source-adapter integration boundary
61
81
  - Docker image support
62
82
  - public Helm chart support
63
83
  - unit and e2e test tasks
64
- - health and metrics endpoints
84
+ - health, PostgreSQL slot readiness, and retained-WAL metrics endpoints
65
85
  - canonical CDC dispatch counters through a `CDC::Core::Observer`
66
86
  - explicit extension registries for state, destination, and runtime adapters
67
87
  - node identity and local capability reporting
68
88
  - lifecycle hooks, configuration providers, and reusable local command objects
69
89
 
90
+ ## Feature Examples
91
+
92
+ The runnable examples are organized around production behaviors and failure
93
+ modes, not isolated API snippets.
94
+
95
+ | Example | v1 capability demonstrated |
96
+ | --- | --- |
97
+ | [`live_postgres_webhook`](examples/live_postgres_webhook) | End-to-end PostgreSQL logical replication into webhook delivery. |
98
+ | [`transaction_webhook`](examples/transaction_webhook) | TransactionEnvelope preservation through the concurrent runtime. |
99
+ | [`webhook_fanout`](examples/webhook_fanout) | Routed multi-destination fanout, environment-backed headers, signing, and independent retry policies. |
100
+ | [`ordering`](examples/ordering) | Ordered and throughput-oriented transaction scheduling. |
101
+ | [`checkpoint_recovery`](examples/checkpoint_recovery) | Durable restart recovery, replay suppression, checkpointing, and acknowledgement. |
102
+ | [`slot_invalidation_recovery`](examples/slot_invalidation_recovery) | Fail-closed slot invalidation and explicit operator reconciliation. |
103
+ | [`composite_replica_identity`](examples/composite_replica_identity) | Composite, non-`id` replica identity across `INSERT`, `UPDATE`, and `DELETE`. |
104
+ | [`postgres_observability`](examples/postgres_observability) | Slot readiness and Prometheus metrics correlated with PostgreSQL catalogs. |
105
+ | [`schema_evolution`](examples/schema_evolution) | Consumer-first additive schema evolution without implying DDL delivery. |
106
+ | [`destination_idempotency`](examples/destination_idempotency) | Atomic destination-side duplicate suppression across isolated relay ledgers. |
107
+ | [`failing_webhook_retry`](examples/failing_webhook_retry) | Retry exhaustion and durable dead-letter persistence. |
108
+ | [`operational_state`](examples/operational_state) | Inspectable checkpoints, delivered ledgers, and dead letters. |
109
+ | [`kubernetes_helm`](examples/kubernetes_helm) | Single-consumer Kubernetes deployment using the public Helm chart. |
110
+
111
+ See [`examples/README.md`](examples/README.md) for the complete index, boundary
112
+ notes, and commands.
113
+
114
+ ## v1 Compatibility
115
+
116
+ Mammoth 1.x treats its validated configuration, serialized webhook envelopes,
117
+ documented CLI command behavior, and forward operational-state migrations as
118
+ supported contracts. Compatible minor releases may add optional configuration
119
+ or payload fields, but do not remove or reinterpret existing fields.
120
+
121
+ Human-readable CLI formatting and PostgreSQL-derived row columns are not frozen:
122
+ scripts should rely on documented exit behavior, while receivers must tolerate
123
+ additive fields and coordinate source schema changes. See
124
+ [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) for the complete promise and
125
+ major-version boundaries. See
126
+ [`docs/WEBHOOK-PAYLOADS.md`](docs/WEBHOOK-PAYLOADS.md) for the canonical event
127
+ and transaction JSON contracts, column-change semantics, and event-ID behavior.
128
+
70
129
  ## Boundary
71
130
 
72
131
  Mammoth begins at CDC-core work items and ends at webhook fanout delivery.
@@ -82,7 +141,9 @@ For the live PostgreSQL stream, `pgoutput-source-adapter` incrementally owns
82
141
  `Begin`/`Commit` buffering and emits exact `CDC::Core::ChangeEvent` or
83
142
  `CDC::Core::TransactionEnvelope` work items. Mammoth only composes the
84
143
  transport, parser, decoder, and source adapter and forwards the resulting core
85
- work to delivery.
144
+ work to delivery. Mammoth's publication preflight supplies ordered,
145
+ catalog-derived replica-identity columns to the adapter, which owns composite
146
+ and non-`id` key extraction.
86
147
 
87
148
  At the downstream boundary, `Mammoth::DeliveryProcessor` implements
88
149
  `CDC::Core::Processor` and returns `CDC::Core::ProcessorResult`. Inline and
@@ -178,6 +239,7 @@ rather than opening SQLite directly.
178
239
  Mammoth includes local benchmarks for the product surfaces operators tune in
179
240
  production:
180
241
 
242
+ - event and transaction serialization, including deterministic fallback IDs
181
243
  - concurrent delivery runtime
182
244
  - real webhook delivery
183
245
  - multi-destination webhook fanout
@@ -206,7 +268,17 @@ script/test-e2e
206
268
  ```
207
269
 
208
270
  The e2e task uses a real HTTP receiver, real SQLite database, and real
209
- filesystem paths.
271
+ filesystem paths. Set `MAMMOTH_E2E_POSTGRES_URL` to include the real PostgreSQL
272
+ logical-replication scenarios:
273
+
274
+ ```bash
275
+ MAMMOTH_E2E_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:5432/mammoth_e2e \
276
+ bundle exec rake test:e2e
277
+ ```
278
+
279
+ The PostgreSQL fixture must have `wal_level=logical`; its test role must be able
280
+ to create publications and logical replication slots, terminate replication
281
+ backends, and change `max_slot_wal_keep_size`.
210
282
 
211
283
  ## Kubernetes
212
284
 
@@ -225,6 +297,14 @@ helm install mammoth charts/mammoth
225
297
  The chart uses one replica and `Recreate` strategy to respect PostgreSQL's
226
298
  logical replication slot constraint: one slot, one active subscriber.
227
299
 
300
+ Production operators should also monitor retained WAL and slot readiness,
301
+ configure PostgreSQL retention guardrails, and alert on database disk and
302
+ catalog health. DDL and sequence state are not replicated; coordinate schema
303
+ changes with webhook consumers and synchronize sequences externally when
304
+ building a writable database copy. See
305
+ [`docs/POSTGRESQL.md`](docs/POSTGRESQL.md) and
306
+ [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md).
307
+
228
308
  ## License
229
309
 
230
310
  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