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. 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
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, and the bounded in-flight window.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).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].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. - 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.