All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.0 - 2026-07-08
First public release: the complete v1 zero-loss streaming CDC core plus every
delivery slice — initial snapshot/backfill, the lib-owned checkpoint store for
non-transactional sinks, batched checkpointing, sink-owned atomic batch
delivery, pgoutput proto-v2 in-progress-transaction streaming, and
consumer-side disk spill for oversized transactions — each closeout-reviewed
against a real-PG16 crash-injection suite (loss = 0, effect-dup = 0).
Added — Consumer-side disk spill for oversized transactions (replicant-streaming-spill)
- Consumer-side disk spill (opt-in
streaming: [spill: [dir: …, max_spill_bytes: …]]): a single in-progress streamed transaction larger thanmax_inflight_lagreassembles partly on disk and delivers effect-once as a lazy, single-pass, disk-backed%Transaction{changes: …}(anEnumerable.t(),Replicant.Spill.Reader) — instead of hitting the §4 fail-closed halt. Two ceilings: resident RAMmax_inflight_lag(the spill trigger) and diskmax_spill_bytes(:spill_exhaustedhalt; default16 × max_inflight_lag,dirdefault a0700subdir ofSystem.tmp_dir!()). The §4 in-flight-lag numerator isreceived − floor − spilled(spilled bytes are on disk, not RAM, so a legitimately-spilling txn is not counted toward the RAM halt) compared tomax_inflight_lag + max_spill_bytes(RAM + disk); resident RAM is bounded by the spill trigger. A newReplicant.Spillmodule is the soleFile.*+ at-rest boundary (0700dir /0600per-txn files, length-prefixed frames, per-slot startup sweep, value-free:spill_io_failed); spill files are ephemeral non-fsync'd scratch, deleted on commit/abort/reset/halt. Delivery obligation: a spilled txn'schangesis single-pass and valid only during thehandle_transaction/1/handle_batch/1call — iterate it withEnum/Stream(neverlength/Enum.to_list, which would force the whole txn into RAM); do not retain it past the call. Composes with the batch modes (a spilled txn buffered into a sink-owned batch migrates its file to the batch, delivered/deleted at flush). Emits[:replicant, :stream, :spilled]and[:replicant, :stream, :spill_exhausted](both value-free). No in-lib encryption — a persistentdiris the operator's to place on a secure/encrypted volume and to clean on decommission.
Added — Sink-owned atomic batch delivery (replicant-batch-delivery)
- Sink-owned atomic batch delivery (
batch_delivery:): an optionalhandle_batch/1sink callback delivering N committed transactions as one atomic unit, amortizing a transactional sink's per-commit cost. Preserves effect-once (dup=0, loss=0). Opt-in via a top-levelbatch_delivery: [max_transactions: 100, max_delay_ms: 1000](sink-owned only; mutually exclusive withcheckpoint_store). Emits[:replicant, :sink, :batch_committed].
Added — Batched checkpointing (lib mode) (replicant-batching)
- Batched checkpointing (lib mode). Opt-in
checkpoint_store: [batch: [max_transactions: 100, max_delay_ms: 1000]]defers the lib-owned checkpoint write + slot ack to once per batch, amortizing the per-transaction store round-trip. Sink delivery stays per-transaction; the sink contract is unchanged. loss=0 is unconditional; the crash/stop dup bound widens to one batch. An auto LSN-span cap (max_inflight_lag/4) keeps a batch from self-tripping the §4 in-flight-lag halt.
Added — Bounded-retry-then-halt on checkpoint-store faults (replicant-store-fault-retry)
:checkpoint_storegains two retry-policy keys:max_retries(default 5, non-negative integer;0= halt-now) andretry_backoff_ms(default 1000, positive integer). A transient connect-read store fault now pacesmax_retriesFRESH reconnects (each re-runs the full connect chain, so slot invalidation is re-checked every attempt) instead of retrying UNPACED forever; a transient mid-stream checkpoint write fault retriesmax_retriestimes — blocking the serial applier, so duplicate-bounded-to-one is preserved — instead of halting on the first fault. A permanent fault (:checkpoint_store_schema_mismatch/:config_invalid) halts immediately, 0 retries. On exhaustion the pipeline halts fail-closed viaSupervisor.halt(loss = 0 preserved). The default policy tolerates ~5s of store outage before halting. Sink-owned mode is untouched. Resolves the checkpoint-store closeout design-decision F3.- New value-free telemetry
[:replicant, :checkpoint_store, :retrying](slot_name,attempt,max_retries) fires on each retry;attempt+max_retriesadded to the value-free telemetry allowlist.
Added — Lib-owned checkpoint store (non-transactional sinks) (replicant-checkpoint-store)
- A second checkpoint mode, selected once in
Replicant.Configby the presence of a:checkpoint_storeoption. Absent → today's sink-owned path, unchanged. Present → the library owns the checkpoint for a non-transactional sink (files, S3, Kafka, external APIs) by writing it to a durable Postgres table after the sink confirms persist. Guarantee: at-least-once, duplicate bounded to one transaction, never loss — NOT effect-once (a non-transactional sink cannot dedup). Replicant.CheckpointStore— a supervised GenServer over a normal Postgrex connection owning onereplicant_checkpointsrow per slot (commit_lsn bigint; validated table identifier, values bound$n). LazyCREATE TABLE IF NOT EXISTS+information_schemashape-probe (a wrong pre-existingcommit_lsntype halts:checkpoint_store_schema_mismatch), non-sync connect for boot resilience, all behind the value-free error boundary (Critical Rule 1).- The checkpoint write lives in the
Assembler'sapply_sink, after the sink returns{:ok, _}and before the[:replicant, :sink, :committed]telemetry / ack (checkpoint-after-persist); a write fault halts:checkpoint_store_failed, never announcing commit. The three checkpoint reads redirect to the store: the connect-time authority (a store read fault fail-closes to a retryable disconnect, never streaming past an unknown checkpoint), the go-forward guard (deferred from config to connect), and the watermark pre-skip (an in-memory watermark seeded once from the connect read). snapshot: truecomposes with lib mode: the snapshot handoff LSN is written to the store (the sink'shandle_snapshot_complete/1is not called in lib mode), ordered before streaming; a handoff write fault halts:snapshot_handoff_failed(whole-snapshot redo on operator restart).Replicant.Sink:checkpoint/0is now an@optional_callbacksentry — a lib-mode sink implements onlyhandle_transaction/1(persisting data; its returned LSN is ignored).Configenforcescheckpoint/0presence at start for sink-owned mode only.New telemetry
[:replicant, :checkpoint_store, :written | :read | :failed]; newReplicant.Errorreasons:checkpoint_store_failedand:checkpoint_store_schema_mismatch;slot_nameadded to the value-free telemetry allowlist.
Added — Initial snapshot / backfill (replicant-snapshot)
snapshot: truestart mode:Replicant.start_link/1bootstraps a:state_mirror(or any snapshot-capable) sink from an already-populated source and hands off to streaming at the snapshot LSN — gap-free and dup-free by the existing transaction watermark. Composes withgo_forward_onlyand resume (bothtrue→:conflicting_start_mode).Replicant.Sinkgains two@optional_callbacks:handle_snapshot/2(batch upsert;first_for_table?triggers the per-table reset — a hard redo-safety obligation) andhandle_snapshot_complete/1(the durable checkpoint handoff).%Change{op: :snapshot}carries backfill rows.Replicant.Snapshotterreads the publication's tables at the exportedconsistent_pointvia aREPEATABLE READcursor on a separate connection, behind a value-free error boundary (Critical Rule 1).EXPORT_SNAPSHOTslot creation +SET TRANSACTION SNAPSHOT(snapshot-name-literal validated, not the identifier allowlist) + server-sideformat('%I.%I')table quoting.- Fail-closed crash recovery: a mid-COPY crash halts
:snapshot_incomplete(never auto-drops a slot); a checkpoint read fault in snapshot mode halts:checkpoint_unreadable. The operator drops the slot to retry. Replicant.lsn_from_string/1;Replicant.Errorreason:snapshot_failed.
Added — Plan 1: offline CDC core (decode / assemble / validate / redact)
- Vendored
pgoutputbyte parser behind a value-free-error decode boundary (Replicant.Decoder.decode/1) — catches every raise and scrubs raw bytes so no row value ever reaches an error, log, or telemetry event (Critical Rule 1). - Type-aware value casting (
Replicant.Casting.Types/Replicant.Casting.ArrayParser) and the OID→type database (Replicant.Decoder.OidDatabase), vendored from walex (MIT; credited inNOTICE). Replicant.Assembler— pgoutput message stream →%Replicant.Transaction{}: transaction-granularity commit LSN, unchanged-TOAST extraction (the sentinel is a first-classunchangedlist, never inrecord), watermark skip (commit_lsn <= checkpoint), additive/destructive schema-change classification with fail-closed halt, and synchronous per-transaction sink apply.- Data contract structs:
Replicant.Transaction,Replicant.Change(+Change.Column),Replicant.SchemaChange; the LSN facade (Replicant.lsn/0uint64,lsn_to_string/1). Replicant.Sinkbehaviour with@optional_callbacks(a minimal sink compiles clean).- Identifier allowlist (
Replicant.Identifier) + validated slot/publication SQL builder (Replicant.QueryBuilder), hardening walex's raw interpolation against injection (Critical Rule 2). - Structure-only telemetry allowlist (
Replicant.Telemetry) — LSNs/counts/table names/durations/error classes only, never row values (Critical Rule 1). - Typed, value-free
Replicant.Error. - Real-
pgoutputbyte conformance suite (walex-captured fixtures) covering every message type including the unchanged-TOAST sentinel and all replica-identity modes — runs with no live database.
Fixed — Plan 1 closeout review (2026-07-04)
- The assembler's value-free boundary now catches sink
throw/exit(not only raises), scrubbing them value-free — a sink exit reason (e.g. aGenServer.calltimeout) can embed the transaction's row values (Critical Rule 1). - A row or truncate for a relation never seen in the stream halts fail-closed instead of emitting a table-less empty change checkpointed as success.
- A replica-identity change expressed via the
:key-flagged column set (aREPLICA IDENTITY USING INDEX/ primary-key swap with the enum unchanged) now classifies:destructive(spec §7/§9), not silently unhandled. old_recordis key-only under non-FULL replica identity — the NULL placeholders a key tuple carries for non-key columns are dropped (spec §7).- A multi-relation
Truncateassigns each relation a unique, monotonicordinal(previously all shared one, colliding with a following change's ordinal). - Sink raise/throw/exit failures are labeled
:sink_failed(distinguishable from a casting:decode_failure).
Added — Plan 2: live streaming + exactly-once
Replicant.Connection(Postgrex.ReplicationConnection) — owns the replication slot and advances it only after the sink durably commits: keepalive replies and the async ack report the last durably-checkpointed LSN as the flush position (never the receivedwal_end— fixes walex's fire-and-forgetwal_end+1at-most-once ack). Decodes each WAL message behind the value-free boundary and forwards decoded messages to the assembler; never blocks on the sink.- Slot-invalidation fail-closed halt (spec §8 R-ISO) — detects
wal_status = 'lost'orconflictingon PG16 (notinvalidation_reason, which is PG17+) and halts the pipeline permanently rather than silently recreating the slot. Replicant.AssemblerServer— a serial process that applies the sink synchronously off the keepalive path; halts fail-closed on a destructive schema change or a sink write fault.Replicant.Pipeline(:one_for_all) +Replicant.Supervisor(DynamicSupervisor) + the OTPApplicationcallback + a namedRegistry.- Go-forward-only start guard (
Replicant.Config) — refuses a:state_mirrorsink resuming from an empty checkpoint withoutgo_forward_only: true. - Bounded in-flight window + fail-closed "sink cannot keep up" lag-halt (spec §4) —
the Connection tracks un-checkpointed WAL lag and halts fail-closed past a
configurable
:max_inflight_lag(default 64 MiB backlog ceiling) rather than growing the assembler mailbox unboundedly; keepalive-safe (never blocks the Connection). byte_size+lag_mson[:replicant, :transaction, :assembled]; the[:replicant, :connection, *]and[:replicant, :checkpoint, :advanced]events.- Gated crash-injection integration suite (real PG16,
wal_level=logical): baseline exactly-once, crash-and-resume (loss = 0), re-delivery dedup (effect-dup = 0), mid-transaction + during-keepalive kills, the §4 backpressure spike, and an independent PG16pgoutput-conformance capture. postgrex ~> 0.22.2dependency (co-resolves withdecimal ~> 3.1; floor is 0.22.2 for CVE-2026-32687).
Fixed — Plan 2 closeout review (2026-07-05)
- Data loss on a missing slot with a live checkpoint — an absent
pg_replication_slotsrow was unconditionally recreated; with a non-empty sink checkpoint the fresh slot streamed from its creation LSN, silently skipping the WAL between the checkpoint and now. Now halts fail-closed with a:data_gapsignal when the checkpoint is non-empty; an empty checkpoint (first run / go-forward) still creates the slot (spec §8 / §14.19). - Over-advance on a sink-returned LSN — the ack advanced to whatever LSN
handle_transaction/1returned; a value higher than the transaction's own commit LSN would advance the slot past un-persisted WAL. The ack now uses the knowntxn.commit_lsn(spec §2 / §14.20). - Go-forward guard fail-open on an invalid
sink_kind— an unrecognizedsink_kind/0return was treated as the laxer:append_log; a typo could let an empty:state_mirrorsink start and partial-deliver. Unknown kinds now coerce to the strict:state_mirrordefault. - Caller
:connectionopts could override library control opts — a callersync_connect/name/auto_reconnectin:connectionwon over the library's, breaking the non-blocking facade or Registry wiring. The library's control opts now take precedence. - Sink write-fault recovery contract clarified — a sink write fault is a permanent fail-closed halt (operator restart required), not auto-retry (spec §6 / §14.18).