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),lsn_to_string/1(uppercase"file/offset"hex display, matching Postgrespg_lsn), andlsn_from_string/1({:ok, lsn} | {:error, :invalid_lsn}since 1.3.0 — a public function never raises, so a caller's input can never reach a crash report).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.SessionIdentity— the system identifier, timeline, current LSN, and database returned byIDENTIFY_SYSTEMon the exact replication connection. A source-bound sink implementshandle_session_identity/2and returns:okonly after accepting the identity plus context slot/publications. This callback always precedes checkpoint lookup and runs again on reconnect; a separate preflight connection is not authoritative.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.handle_slot_origin/2(optional) — receives the slot's typed consistent-point origin LSN a go-forward stream begins at, on every connect/reconnect beforeSTART_REPLICATION, for a go-forward append consumer.contextis%{slot_name, reused?}(value-free):reused?: false→ theCREATE_REPLICATION_SLOTconsistent_point (new slot);reused?: true→ the greater of the durable checkpoint and the slot's liveconfirmed_flush_lsn(the effectiveSTART_REPLICATIONorigin for a resumed slot). Return:okto proceed; any other return/raise/throw/exit halts fail-closed (:slot_origin_rejected) — a veto for an origin that gapped past the consumer's last appended LSN, instead of silently skipping WAL. A missing/NULL/malformed database origin halts before the callback with:slot_origin_unavailable; origin0is never fabricated. A sink without the callback is unaffected (no extra query). An:append_logsink does not perform the generic filtered-WAL idle advance, so a reused origin ahead of its checkpoint is a real gap. Publish a normal heartbeat transaction on a quiet append publication to advance the checkpoint and release retained WAL.sink_kind/0(optional) — declares how the sink consumes the stream: the default (callback absent) is a state mirror (replays/upserts state; the generic idle keepalive may advance the slot over filtered WAL),:append_logis a go-forward append consumer (never acknowledges past its durable delivered checkpoint, so an out-of-band slot advance is detectable as a gap on reconnect). This is the knob behind the 1.2.3 append-log ack behavior.handle_schema_change/2(optional) — consulted on a destructive schema change (a dropped column, a replica-identity change, a narrowing type change). Default behavior without the callback: additive changes apply automatically, destructive changes halt fail-closed. With it, return:okto accept the change (you are asserting your sink can handle the new shape) or{:error, reason}to halt value-free — a migration-window hook for sinks that can adapt their own schema.contextis value-free.- 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, or:backfill_pendingafter durably arming the backfill but before the first chunk); lib mode carries progress in the checkpoint store, so onlyhandle_snapshot/2is required. A pending restart reads the live slot origin and resumes discovery instead of silently selecting stream-only delivery. A concurrent write to a backfilling row wins over its stale chunk row (collision-corrected). Keyed drop-cap contention and PK-less whole-table contention both halt:snapshot_table_contendedafter three discarded attempts; reconnects do not consume that reader-local budget. 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: actual-session identity before checkpoint lookup, ack-after-checkpoint keepalive replies (state mirrors may idle-advance over filtered WAL; append logs never do), 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).streaming: [max_concurrent_txns: N](default 64) opts into proto-v2 in-progress transaction streaming; its nestedspill: [dir:, max_spill_bytes:]opts into consumer-side disk spill for oversized streamed transactions.max_inflight_lag(default 64 MiB) bounds how far the received WAL frontier may run ahead of the durable checkpoint before the sink is "too slow" (see the halt-reason table below).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. Withsnapshot: [mode: :incremental]the store ALSO owns a second table —progress_table(default"replicant_snapshot_progress", one row per slot) — carrying the opaque, value-free backfill progress token. Both table names are configurable (checkpoint_store: [table: ..., progress_table: ...]) and both are created lazily. 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.
Value casting — what record holds
%Change{}.record values are cast from Postgres's text output (ADR-0008 for the
full contract). The guarantees a sink can rely on:
- Lenient by default. A value that fails its type's parse is delivered as the
original string, never dropped or raised — EXCEPT a small documented raise-site
set (malformed
numeric, non-hexbytea) that scrubs to a value-free:decode_failurehalt at the decode boundary: genuinely-malformed input is a halt, an unparseable-but-legitimate value is a string. - Multidimensional arrays nest. Every casted array type (
numeric[][],timestamptz[][],jsonb[][],bool[][], …) delivers nested lists with the same per-element semantics as its scalar clause.NULLelements arenilat any depth (since 1.3.0; before it, onlyint[]/float[]recursed and other 2-D arrays halted).interval[]andtimetz[]deliver raw-string elements, mirroring their scalar raw-string clauses (since 1.3.0;interval[]previously fell into the integer-array clause and was silently truncated to its leading integer). moneyis locale-honest. Money output follows the server'slc_monetary. The strict C/en-US shapes ($1,234.56,-$5.00,$1234567.89) deliver aDecimal; anything else (e.g. ade_DE"1.234,56") delivers the original string — never a silently-wrong Decimal, never a raise. AcceptDecimal | String.t()for money columns, and normalize the string form in your sink if you run a non-C locale (since 1.3.0).timetzdelivers the raw server string (fractional seconds and offset preserved; there is no Elixir type for time-with-offset — same asinterval). Before 1.3.0 the offset and fraction were silently truncated.type_modifieris a signed int32 (-1is Postgres's "no modifier" marker, not4294967295).
Telemetry reference
Every event is value-free by construction: metadata keys are closed to
commit_lsn, change_count, byte_size, lag_ms, duration, attempt, max_retries, transactional, table, slot_name, reason, error_class, kind, each with a
value-shape contract enforced at emission (Replicant.Telemetry raises on an
off-list key or a wrong shape — never ships a row value). All thirty events:
| Event | Fires when | Measurements | Metadata |
|---|---|---|---|
[:replicant, :connection, :connected] | replication connection established (each connect/reconnect) | — | kind (:primary/recovery kind) |
[:replicant, :connection, :disconnected] | connection dropped — also the only signal of the :sink_too_slow lag halt (with reason: :sink_too_slow and a signed lag measurement in bytes) | lag (halt only) | reason (halt only) |
[:replicant, :connection, :slot_active] | the slot is created/owned and streaming begins | — | — |
[:replicant, :connection, :slot_invalidated] | slot invalidation / fail-closed config rejection | — | reason (:failover_unsupported, :publication_missing, invalidation class) |
[:replicant, :connection, :session_identity_rejected] | the sink vetoed the replication-session identity | — | reason: :session_identity_rejected |
[:replicant, :connection, :command_error_halt] | the pre-frame command-error watchdog exhausted its budget | — | attempt, max_retries, slot_name |
[:replicant, :checkpoint, :advanced] | the checkpoint advanced (per txn, async ack, or idle advance) | — | commit_lsn (kind: :idle on the idle-advance path) |
[:replicant, :checkpoint_store, :read] | lib mode: checkpoint read at connect | — | slot_name, commit_lsn (nil on a fresh slot) |
[:replicant, :checkpoint_store, :written] | lib mode: checkpoint durably written | — | slot_name, commit_lsn |
[:replicant, :checkpoint_store, :batch_flushed] | lib mode: a batched checkpoint window flushed | — | slot_name, change_count, byte_size (LSN span) |
[:replicant, :checkpoint_store, :retrying] | a transient store fault is being retried | — | slot_name, attempt, max_retries |
[:replicant, :checkpoint_store, :failed] | a store fault halted (retry exhaustion or permanent) | duration (mid-stream) | slot_name, reason |
[:replicant, :transaction, :assembled] | a committed transaction was assembled for delivery | — | commit_lsn, change_count, byte_size |
[:replicant, :sink, :committed] | the sink returned {:ok, lsn} for a transaction | duration | commit_lsn |
[:replicant, :sink, :batch_committed] | handle_batch/1 committed an atomic batch | duration | commit_lsn, change_count, reason |
[:replicant, :sink, :failed] | the sink returned an error / raised (halt follows) | duration | reason (:sink_failed, :spill_io_failed, …) |
[:replicant, :message, :received] | a logical-decoding message arrived (messages: true) | — | commit_lsn, byte_size, transactional |
[:replicant, :schema_change, :additive] | an additive schema change applied automatically | — | table, kind: :additive |
[:replicant, :schema_change, :halted] | a destructive schema change halted (or handle_schema_change/2 vetoed) | — | table, kind: :destructive |
[:replicant, :snapshot, :started] | a snapshot/backfill started (point-in-time or incremental floor) | — | commit_lsn |
[:replicant, :snapshot, :resumed] | an incremental backfill resumed from durable progress | — | slot_name |
[:replicant, :snapshot, :table_completed] | one table finished snapshotting | — | table, change_count |
[:replicant, :snapshot, :chunk_completed] | one incremental chunk applied | — | table, change_count |
[:replicant, :snapshot, :chunk_retried] | a chunk was discarded for contention and will retry | — | table, reason: :snapshot_table_contended |
[:replicant, :snapshot, :completed] | the snapshot finished and handed off to streaming | duration | commit_lsn, change_count |
[:replicant, :snapshot, :failed] | the snapshot halted (value-free boundary) | — | reason (:snapshot_failed, :snapshot_table_contended, …) |
[:replicant, :stream, :committed] | a proto-v2 streamed transaction committed | — | commit_lsn, byte_size (change_count on the folded path) |
[:replicant, :stream, :aborted] | a streamed transaction aborted | — | reason: :stream_abort |
[:replicant, :stream, :spilled] | an oversized streamed transaction's tail spilled to disk | — | byte_size, change_count |
[:replicant, :stream, :spill_exhausted] | the disk ceiling was reached (halt follows) | — | byte_size, reason: :spill_exhausted |
The two-column trap to know: the lag halt (:sink_too_slow) rides
[:replicant, :connection, :disconnected], not a dedicated event — alert on
reason: :sink_too_slow, not on the event name alone.
Halt reasons (operator reference)
Every failure mode halts fail-closed — the pipeline stops and stays idle rather than dropping data or reconnecting forever. Restart resumes from the durable checkpoint (loss = 0; duplicates bounded by the mode's contract).
| Halt signal | Cause | Operator action |
|---|---|---|
[:replicant, :sink, :failed] | the sink returned an error or raised | fix the sink/store it writes; restart the pipeline |
:disconnected + reason: :sink_too_slow | received WAL ran max_inflight_lag (default 64 MiB) past the durable checkpoint | speed the sink up, batch (batch_delivery), or raise the bound |
[:replicant, :connection, :slot_invalidated] | the slot was invalidated (wal_status = 'lost'), a requested publication vanished, or failover config was rejected | recreate the slot (accept the re-stream from its origin — see handle_slot_origin/2 for append sinks); restore/rename the publication; drop failover: on PG<17 |
[:replicant, :connection, :command_error_halt] | persistent pre-frame command errors (slot exhaustion, slot already active, forward-incompatible result) | free a replication slot / disconnect the other consumer; then restart |
[:replicant, :connection, :session_identity_rejected] | the sink vetoed the actual replication-session identity | point at the intended source, or update the sink's accepted identity |
:slot_origin_rejected / :slot_origin_unavailable | an append sink vetoed the go-forward origin / the slot's origin state was missing or malformed | reconcile the append log with the origin gap, then restart |
[:replicant, :checkpoint_store, :failed] | the lib-mode checkpoint store faulted past its retry budget (or a permanent schema/config fault) | restore the store, fix the schema/config; restart |
[:replicant, :schema_change, :halted] | a destructive schema change (dropped column, replica-identity change, narrowing type) — or handle_schema_change/2 vetoed | adapt the sink (or implement the callback to accept), then restart |
[:replicant, :snapshot, :failed] with :snapshot_table_contended | a backfilling table stayed hot through three contention attempts | backfill during a quieter window, or shrink chunk_rows |
[:replicant, :snapshot, :failed] (other) | the snapshot reader faulted (incl. connection faults during a backfill) | check source connectivity; restart — the backfill resumes from durable progress |
[:replicant, :stream, :spill_exhausted] | a spilled transaction exceeded max_spill_bytes | free disk / raise max_spill_bytes / shrink the oversized transaction |
:decode_failure | genuinely-malformed WAL/cast input at the value-free boundary | check upstream WAL/plugin integrity — this is never ordinary data |
:slot_synced_unpromoted | failover: true against a standby whose synced slot is not yet promoted | promote the standby (or point at the primary), then restart |
Start-time rejections (no pipeline starts, nothing halts): :invalid_identifier,
:invalid_sink, :config_invalid, :conflicting_start_mode,
:go_forward_required, :snapshot_unsupported, :batch_unsupported,
:messages_unsupported, {:config, :failover_unsupported}, and
{:error, :invalid_start_lsn} from the query builder (1.3.0).
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 docs/INVARIANTS.md for the full published rules.