A framework-agnostic Elixir CDC consumer for Postgres logical replication (pgoutput).
What replicant is (and is not)
- Is: a reliable CDC consumer. Decodes the
pgoutputlogical replication stream, assembles committed transactions, validates schema changes, and delivers each transaction to a pluggable sink with sink-owned, transaction-granularity exactly-once semantics. - Is not: Ash-aware, tenant-aware, or classification-aware. Never put
multitenancy or sensitive-data logic here — that is
ash_replicant's job. - Is: a live streaming client. Owns the replication slot via
Postgrex.ReplicationConnection, acks only after the sink durably commits (ack-after-checkpoint), and halts fail-closed on slot invalidation — proven by a real-PG16 crash-injection suite (loss = 0, effect-dup = 0).
Public surface
Replicant— the facade module.start_link/1starts a supervised streaming pipeline (validates opts, enforces the go-forward guard) andstop/1tears one down;t:lsn/0(anon_neg_integer64-bit LSN,(file <<< 32) ||| offset) andlsn_to_string/1(uppercase"file/offset"hex display, matching Postgrespg_lsn).Replicant.Transaction— an assembled, committed transaction: ordered changes plus the transaction's singlecommit_lsn, and (whenmessages: trueis enabled) any transactional logical-decoding messages inmessages(a[Replicant.Decoder.Messages.Message.t()]— these ride the txn and inherit itscommit_lsneffect-once dedup). Ordinarilychangesis aList; for an oversized spilled streamed transaction (opt-instreaming: [spill: [dir: …, max_spill_bytes: …]]) it is a lazy, single-pass, disk-backedEnumerable(Replicant.Spill.Reader) valid only during thehandle_transaction/1/handle_batch/1call — iterate it withEnum/Stream, neverlength/1/Enum.to_list/1(which force the whole transaction back into RAM, defeating spill), and do not retain it past the call. Spill emits value-free[:replicant, :stream, :spilled](a tail flushed to disk) and, when disk usage would exceedmax_spill_bytes,[:replicant, :stream, :spill_exhausted]before the fail-closed halt — attach to alert operators on exhaustion.Replicant.Change— a single row change (insert/update/delete), the decodedrecord, and theunchangedlist of TOASTed columns the source UPDATE did not touch (never a value — sinks must leave those columns alone).Replicant.SchemaChange— a detected DDL-shape change (column add/drop, type change, replica-identity change). Destructive changes halt fail-closed.Replicant.Sink— the behaviour a consumer implements. In the default sink-owned transaction mode (batch_deliverynot set), receives oneReplicant.Transactionat a time and must durably persist it (or raise) before the slot advances past itscommit_lsn. When sink-owned batch delivery is enabled (batch_delivery: [max_transactions: N, max_delay_ms: T]), receives N committed transactions in a singlehandle_batch/1call and must persist all rows- checkpoint atomically — the transaction is atomic and the effect-once guarantee (dup=0, loss=0)
rests on it. Transactions arrive in ascending
commit_lsnorder; the sink must skip anycommit_lsn <= checkpointand upsert rows by table PK. A non-{:ok, _}return (or a raise/throw/exit) halts the pipeline fail-closed; the batch is discarded un-acked and re-delivered on resume, deduped to zero net effect by the idempotent sink. Optional snapshot callbacks (handle_snapshot/2+handle_snapshot_complete/1) let a sink be bootstrapped from a populated source: batches of%Change{op: :snapshot}rows (upsert by PK; clear the table whenfirst_for_table?is true — a hard redo-safety obligation), then a durable handoff checkpoint at the snapshot's consistent point. In lib mode (:checkpoint_storeconfigured) the library owns the checkpoint, so a sink implements onlyhandle_transaction/1—checkpoint/0is optional there; its returned LSN is ignored. Batch delivery is mutually exclusive with lib mode.
- checkpoint atomically — the transaction is atomic and the effect-once guarantee (dup=0, loss=0)
rests on it. Transactions arrive in ascending
handle_message/2(opt-in,messages: true) — delivers a non-transactional logical-decoding message (pg_logical_emit_messagewithtransactional => false). The guarantee is at-least-once, NOT effect-once (see the rule below): a reconnect between the{:ok}return and the checkpoint advancing can re-deliver the same message. On:ok/{:ok, _}the library advances the checkpoint to the message's LSN; a non-:okreturn (or raise/throw/exit) halts fail-closed.contextcarries%{lsn: lsn}. Amessages: trueconfig whose sink lacks this callback is rejected at start (:messages_unsupported). Transactional messages do NOT route here — they ride%Transaction.messagesand inherit the txn path's effect-once.- Incremental snapshot (
snapshot: [mode: :incremental]) — a resumable, chunked backfill for large tables, interleaved with the live stream. Chunks arrive through the SAMEhandle_snapshot/2(samefirst_for_table?redo-safety obligation;handle_snapshot_complete/1is NOT used — completion rides a dedicated finalhandle_snapshot/2call). A sink-owned incremental sink must ALSO implementsnapshot_progress/0(return the opaquectx.progresstoken it persisted atomically with each chunk); lib mode carries progress in the checkpoint store, so onlyhandle_snapshot/2is required. A concurrent write to a backfilling row wins over its stale chunk row (collision-corrected); PK-less tables fall back to a bounded whole-table redo. Replicant.Decoder—decode/1wraps the vendoredpgoutputbyte parser; catches and redacts any raise into a value-freeReplicant.Error.Replicant.Assembler— groups decoded messages intoReplicant.Transactions bycommit_lsn.Replicant.Connection— thePostgrex.ReplicationConnectionthat owns the replication slot: ack-after-checkpoint keepalive replies, async ack, slot-invalidation fail-closed halt, the bounded in-flight window, and the replication-command-error watchdog (below).Replicant.AssemblerServer— the serial process that applies the sink synchronously off the keepalive path.Replicant.Pipeline— the per-slot:one_for_allsupervisor pairing a Connection with an AssemblerServer; started byReplicant.start_link/1.Replicant.Config— validates pipeline options + the start-mode guard (go_forward_only/snapshot/ resume;go_forward_only+snapshotboth set is refused as:conflicting_start_mode).publication:accepts a single validated name or a list (["p1", "p2"]) for multi-publication; every name is identifier-validated, the connect chain fails closed if any requested pub is absent (:publication_check), andmessages: trueis the opt-in for logical-decoding messages (rejected:messages_unsupportedif the sink lackshandle_message/2).Replicant.Snapshotter— reads a consistent snapshot of the publication's tables at theEXPORT_SNAPSHOTLSN (aREPEATABLE READcursor on a separate connection) and pushes%Change{op: :snapshot}batches to the sink, behind a value-free error boundary; theConnectionhands off to streaming at that LSN.Replicant.CheckpointStore— in lib mode (a:checkpoint_storeoption is present), a supervised GenServer owning onereplicant_checkpointsrow per slot: the library writes the checkpoint (commit_lsn bigint) to this durable Postgres table after the sink persists, so a non-transactional sink (files, S3, Kafka, external APIs) needs no atomic data+checkpoint unit. Value-free boundary; lazy table create + shape-probe. A store fault is bounded by two:checkpoint_storeknobs —max_retries(default 5) andretry_backoff_ms(default 1000) — shared by both fault sites: a transient connect-read fault paces N fresh reconnects, a transient mid-stream write fault retries N (blocking the applier, so dup-bounded-to-one holds), then both halt fail-closed on exhaustion (Supervisor.halt, loss = 0). A permanent fault (schema mismatch /:config_invalid) halts immediately;max_retries: 0opts out of retry (halt-now). Each retry emits[:replicant, :checkpoint_store, :retrying].- Replication-command-error watchdog — the top-level
max_command_retriesoption (default 5) bounds a persistent PRE-FRAME command error —CREATE_REPLICATION_SLOTfailing because the server's replication slots are exhausted, a slot already active for another consumer, or a forward-incompatible result shape — which otherwise reconnects forever viaauto_reconnect. Aftermax_command_retriesfailed connect cycles without the stream establishing, the pipeline halts fail-closed and stays idle, emitting value-free[:replicant, :connection, :command_error_halt](attempt/max_retries/slot_name);max_command_retries: 0halts on the first fault. The bound is a CYCLE count, not a wall-clock time. Sibling of the store-retry bound above but a separate budget (a store outage never trips it). Only PRE-FRAME errors are bounded: once the stream is flowing the counter resets on the first replication frame, so a later transient outage self-heals, and a server that is simply down (connection refused, never establishes) keeps retrying untouched. Replicant.QueryBuilder— builds the identifier-validated SQL used to create/manage slots and publications.Replicant.Identifier— allowlist validation for slot, publication, and other identifiers that reach SQL.Replicant.Telemetry— value-free:telemetryspans (LSNs, table names, counts, durations, error classes — never row values).Replicant.Error— the typed, value-free error struct raised/returned at decode and validation boundaries.
Non-negotiable rules
- No row value in an error, log, or telemetry event. Assume every value is
PII or a secret. Column names are strings, never atoms (
String.to_atomon a wide or attacker-influenced schema exhausts the atom table). - Validate identifiers. Slot and publication names go through
Replicant.Identifier.validate/1before reaching SQL. A failure carries the invalid-shape fact only, never the offending string. - Exactly-once is at-least-once + a transaction-watermark-idempotent sink.
The watermark is the commit LSN at transaction granularity — skip any
transaction whose
commit_lsn <= checkpoint; upsert rows by table PK. There is no naked exactly-once without two-phase commit or an idempotent sink; never claim one. - Lib mode is at-least-once, never effect-once. With a
:checkpoint_store, the library writes the checkpoint after the sink persists (checkpoint-after-persist), so a crash between persist and checkpoint re-delivers exactly one transaction on resume: duplicate bounded to one transaction, never loss. A non-transactional sink cannot dedup — do not claim effect-once for it. - Batching is opt-in and lib-mode only.
checkpoint_store: [batch: [max_transactions: N, max_delay_ms: T]]. It batches the checkpoint write + ack, NOT sink delivery (handle_transaction/1is still per-transaction). A crash or graceful stop mid-batch re-delivers up to one batch — sizemax_transactionsfor your dup tolerance. Do not set:batchat the top level (it belongs under:checkpoint_store; a misplaced top-level:batchis rejected at start). - Sink-owned atomic batch delivery (
handle_batch/1) preserves effect-once. Thebatch_delivery: [max_transactions: N, max_delay_ms: T]config (top-level, sink-owned only; mutually exclusive with:checkpoint_store) routes delivery throughhandle_batch/1instead ofhandle_transaction/1. The HARD OBLIGATION is that the data + checkpoint write is ATOMIC — the effect-once guarantee (dup=0 across mid-batch teardown) rests on it. Transactions arrive in ascendingcommit_lsnorder; the sink must skip anycommit_lsn <= checkpointand upsert rows by table PK, exactly ashandle_transaction/1does. A non-{:ok, _}return (or a raise/throw/exit) halts the pipeline fail-closed; the batch is discarded un-acked and re-delivered on resume, deduped to zero net effect by the idempotent sink. - Failover slots are opt-in and PG17+.
failover: truecreates the replication slot with Postgres'sFAILOVERoption so it syncs to physical standbys, letting a pipeline resume against a promoted standby with zero loss (the slot'sconfirmed_flushposition carries over). PG16 does not supportFAILOVERslots — passingfailover: truethere halts fail-closed ({:config, :failover_unsupported}) instead of silently ignoring the option. Never point Replicant at an unpromoted standby's synced slot — it halts fail-closed ({:slot_synced_unpromoted}) instead of retry-looping against a slot it cannot yet consume. - Multi-publication is opt-in via a list.
publication: "p"stays the byte-unchanged default;publication: ["p1", "p2"]streams the union. Every requested publication must exist — a missing one halts fail-closed at connect (:publication_check) rather than silently streaming the subset (aSTART_REPLICATIONthat names a missing pub streams only the found set). pgoutput de-dupes overlapping tables across pubs on the wire. - Logical-decoding messages: state the guarantee honestly.
messages: trueis opt-in. A transactional message (transactional => truetopg_logical_emit_message) rides%Transaction.messagesand is effect-once (inherits the txncommit_lsndedup). A non-transactional message routes tohandle_message/2and is at-least-once — duplicates are possible on reconnect (no dedup key). Never claim effect-once for the non-transactional path. A message'scontentandprefixare user bytes (Critical Rule 1) — never log them or surface them in telemetry. - Unchanged TOAST is a sentinel, not a value. It surfaces only as
Replicant.Change'sunchangedlist of column names, never inrecord. Sinks must leave those columns untouched on upsert. - Stay tenant-blind. No multitenancy, scope, or classification logic
belongs here — that boundary is the whole reason
replicantandash_replicantare separate libraries.
See AGENTS.md for the full working rules.