The reference pipeline — replicant as a durable Postgres→Postgres replicator
Copy Markdown View SourceOne docker compose up brings up the whole reference stack:
Postgres 18 (source, wal_level=logical + example_pub) ──pgoutput──> [this app]
├─ idempotent orders replica (upsert by PK)
└─ durable commit-LSN checkpoint
──> Postgres 18 (destination)This is a go-forward change replicator: it streams changes that commit from
the moment the pipeline starts. Pre-existing source rows are not backfilled
— snapshot: true is the one-flag alternative for that (it additionally
requires handle_snapshot/2 + handle_snapshot_complete/1 on the sink; see
the repo README's "Start modes").
All credentials are throwaway local-example values, not secrets. Ports bind to 127.0.0.1 only (15432 source / 15433 destination).
Run it
cd examples/replication_pipeline
docker compose up -d --build # source + destination + pipeline
First boot is a full OTP-release build — a few minutes. The pipeline creates its replication slot a few seconds after boot; an insert committed BEFORE the slot exists is never streamed (go-forward starts at the slot's creation point), so confirm the slot is active first:
docker compose exec source-pg psql -U postgres -d example_src \
-c "SELECT slot_name, active FROM pg_replication_slots"
# one row, active = t — ready
# write a row on the source
docker compose exec source-pg psql -U postgres -d example_src \
-c "INSERT INTO orders (id, note) VALUES (1, 'hello')"
# see it on the destination, with its checkpoint
docker compose exec dest-pg psql -U postgres -d example_dst \
-c "SELECT * FROM orders; SELECT commit_lsn FROM pipeline_checkpoint"
Teardown: docker compose down -v.
Prove the durability
docker compose restart pipeline # kill/restart mid-life
docker compose exec source-pg psql -U postgres -d example_src \
-c "INSERT INTO orders (id, note) VALUES (3, 'after restart')"
docker compose exec dest-pg psql -U postgres -d example_dst \
-c "SELECT id, note FROM orders ORDER BY id" \
-c "SELECT count(*) FROM cdc_receipts" # no re-delivery of prior rows
The durable checkpoint row made that resume gap-free and effect-once: the post-restart row arrives alongside the earlier ones, and the receipts for prior transactions are unchanged — a re-delivery would have doubled them.
What each piece teaches
| Piece | The lesson |
|---|---|
ReplicationPipeline.Sink | at-least-once delivery made effect-once by the commit-LSN watermark: data + checkpoint in ONE destination transaction, and the commit_lsn <= checkpoint skip IS the dedup (Critical Rule 3) |
ReplicationPipeline.Sink (receipts) | the value-free cdc_receipts ledger (commit_lsn/schema/table/op, never a value) exists exactly when its transaction took effect — re-delivery is skipped whole |
ReplicationPipeline.Sink (session identity) | the first connect BINDS the source's {system_identifier, database} into the checkpoint row; every later connect COMPARES — a rebuilt source container HALTS the pipeline instead of silently resuming a different database (ADR-0007) |
ReplicationPipeline.Sink (upsert) | unchanged-TOAST columns are OMITTED from the upsert SET — the sentinel never appears in record (Critical Rule 4) |
docker-compose.yml (source flags) | wal_level=logical is the load-bearing flag; the publication is the operator's SQL and a missing one fails closed |
The demo is intentionally minimal (one table, id PK). Real deployments
extend the sink per table — the seam is the point, not the schema.
CI
The reference-example CI job builds this stack every push and drives the
full sequence: the example's own static gates (format, compile-warnings,
credo --strict, dialyzer), then insert → replica + receipts + checkpoint
assertions, TOAST-sentinel survival, and restart-resume with zero duplicate
receipts — so the example can never silently rot out of sync with
replicant's public sink API.