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
1.1.0 - 2026-08-13
Fixed
- Float-array casting no longer halts the pipeline on valid Postgres output (Critical-Rule-1
path, fail-closed). The
_float4/_float8array clauses calledString.to_float/1, which raises on the exact text Postgresfloat4out/float8outemits for whole numbers ("1"), scientific notation ("1e+20"), and the special valuesNaN/Infinity/-Infinity. So adouble precision[]/real[]column holding an ordinary whole-valued element raised insideCasting.Types.cast_record/2; the decode boundary caught it and halted the pipeline fail-closed (no data loss, no value leak), but a schema with a whole-number float-array element permanently stalled the consumer. The array clauses now mirror the scalarfloat*clause (Float.parsefallback + the:nan/:infinity/:neg_infinityatoms) and never raise. For symmetry the_int*array clause is likewise lenient (Integer.parsefallback) instead ofString.to_integer/1. TheCasting.Typesmoduledoc's raise-site list is now accurate (the array bangs it omitted are gone). Covered by a new red-first float-array unit test (whole numbers, scientific notation, special values, NULL, multidimensional nesting).
Changed
- Post-halt incremental-window calls are rejected with
{:error, :window_reset}. After a fail-closed halt, the{:message, ...}cast was already dropped, but the three incremental snapshot-windowhandle_callclauses (open_snapshot_window,deliver_snapshot_chunk,finish_snapshot_table) still ran — soapply_ready_chunkscould callsink.handle_snapshotonce during the async teardown window. Idempotent sinks bound the impact, but this contradicted the halt contract the module states. All three now carry ahalted: trueguard returning{:error, :window_reset}(the reload/stop signal the reader already handles). Covered by a red-first halted-guard unit test. - Snapshotter / incremental-reader connection opts use
Keyword.merge(library wins). Both reader call sites usedconn_opts ++ [pool_size: 1], so a caller-suppliedpool_sizewon over the library's and duplicate keys reached Postgrex. NowKeyword.merge(conn_opts, pool_size: 1)— parity with the checkpoint-store and connection merges, which document the library-wins rule as load-bearing.
1.0.0 - 2026-08-13
Fixed
- Incremental-reader "exactly-one" made structural (was comment-defended). The
incremental-backfill reader is
spawn_link'd to the Connection, and the "exactly one reader per slot" invariant was previously defended only by comments (reader_pidcarried across reconnect +retire_reader/1on every reconnect path). A future reconnect path that forgotretire_readerwould spawn a second reader → double delivery of snapshot chunks. The reader now registers under{:incremental_reader, slot}inReplicant.Registry(:unique) at start; a live prior registration halts fail-closed (:duplicate_reader) instead of double-delivering. Registry auto-frees the key on the owner's death, so the normal retire+restart flow is unchanged. - v1 snapshot value-type convergence.
snapshot: trueshipped Postgrex's native row decode (SELECT *), so a typed column delivered a different runtime type from the snapshot than from the stream — e.g. atimestampcolumn arrived as%NaiveDateTime{}from the v1 snapshot and%DateTime{}from the stream (which casts throughCasting.Types.cast_record/2). The incremental snapshot was fixed; v1 was not. v1 now projects<col>::textand casts each value through the SAME path the stream uses, so the v1 snapshot and the stream deliver byte-identical%Change{}.recordvalues for every type. Also extendscast_recordto recognize bool's full-word::textform ("true"/"false") — PGbool::textemits the word form while pgoutput emits"t"/"f", so both::textsnapshot paths (v1 AND incremental) previously delivered the string"true"for a bool column where the stream delivers booleantrue; both now converge to boolean. (Critical Rule 1 boundary preserved; the stream never sends the word form, so the new clauses fire only on snapshot paths.) - postgrex CVE bump (0.22.2 → 0.22.4).
mix hex.auditreported two advisories on postgrex 0.22.2: CVE-2026-58225 (LOW, dollar-quote inPostgrex.Notificationsreconnect replay, fixed 0.22.3) and CVE-2026-66838 (MEDIUM, SQLi via the:commentoption inPostgrex.stream/4, fixed 0.22.4). Replicant's call sites use neither vector (the snapshotter'sPostgrex.streampasses no:comment; there is noPostgrex.Notificationsusage), but the floor moves to~> 0.22.4so the audit is clean and transitive consumers are not exposed. NoReplicationConnectionAPI change across 0.22.2 → 0.22.4 (security patches only). - Integration suite was silently masked. Every integration module started a NAMED Postgres
pool in
setupand never stopped it; ExUnit'sasync: falseone-process model then made test 2+ fail with{:error, {:already_started, _}}, cascading to setup failure. The full integration suite was 66 tests / 31 failures — roughly half the live-PG16 crash-injection marquees (the project's primary correctness evidence) were not running.PG16.named_conn/2now centralizes per-test isolation (start the pool unlinked + register anon_exitthat stops it); all 23 named-pool sites route through it. The full suite is now 66/0. - Bound the lib-mode incremental-snapshot concurrency marquee's writer and assert real chunk/stream overlap before quiescing it, so constrained CI runners prove convergence instead of being starved by an unbounded WAL source.
Added
Actual replication-session identity. Every connect and reconnect now runs
IDENTIFY_SYSTEMon the exactPostgrex.ReplicationConnectionbefore reading any sink-owned or library-owned checkpoint. The public%Replicant.SessionIdentity{}and optionalhandle_session_identity/2callback let a source-aware sink reject drift synchronously; malformed identity or any callback failure halts with a fixed value-free reason.Foundational ADRs + published Critical Rules. Four ADRs record the load-bearing 1.0 posture decisions a bare-clone maintainer cannot recover from code alone: 0003 the value-free error/log/telemetry boundary, 0004 the commit-LSN transaction-granularity watermark, 0005 spill as ephemeral non-fsync'd scratch, 0006 the
:one_for_all+:temporaryfail-closed supervision. The 5 Critical Rules are published asdocs/INVARIANTS.md(sink-author-facing;AGENTS.mdwas removed from the tarball in 0.2.1 as an agent contract, so this is the published home for the binding invariants). Both ship in the Hex tarball (docsadded to packagefiles) and render on HexDocs.
Changed
Replicant.Assemblersplit into three modules. The assembler was a 1633-LOC god module; it is nowReplicant.Assembler(Core: v1 router + sink-dispatch/scrub cluster + change-building + watermark, 1072 LOC), its streaming helper (proto-v2 reassembly + spill, 398 LOC), and its batch helper (batched-checkpoint buffering + flush, 230 LOC). The%Assembler{}struct is UNCHANGED; every moved function is a pure function on that struct, and the 545-unit + 613-integration suites are the preservation net (green at every commit, no behavior change). The cross-cutting Rule-1 scrub cluster stays intact (every sink-call site keeps its value-freerescue/catch); the moved batch-flush scrub travels as one tamper-tested unit. The Core↔Streaming/Core↔Batch call cycles are runtime-resolved in Elixir; the 8 wideneddefp→definternal seams carry@doc false. Closeout: fresh-context diff-review CLEAN; cross-vendor codex+claude CLEAN; crash-injection marquees loss=0 / effect-dup=0 green against live PG16.- Batch spill-IO fault now labeled
:spill_io_failed(was:sink_failed). A spill-IO fault (a lazy Reader raisingSpill.Errorwhile the sink forced its enumeration during sink-owned batch delivery) is now distinguished from a sink fault — parity with the per-transactiondeliver_nowpath, which already labeled it:spill_io_failed. Still fail-closed and value-free (Critical Rule 1); only the surfaced error reason / telemetry:reasonbecomes more specific for triage. - Batch flush-trigger deduplicated. The lib-batch and sink-owned-batch flush triggers were
two identical
condblocks (count cap OR LSN-span cap OR buffer); extracted to a sharedAssembler.maybe_trip_batch/3so the two modes cannot drift (a drift would silently change the dup bound in one mode). Pure refactor, behavior unchanged. - Conformance suite tamper-evidence is now machine-checked. A parametric byte-flip test (type byte + sampled payload, per message class) proves each real-captured fixture goes red on mutation — previously tamper-red by construction (strict pattern-matches), not by test.
- Install constraint corrected. The README and getting-started Livebook shipped
{:replicant, "~> 0.2"}(=< 0.3.0), locking users out of every 0.3 feature; the prepared 1.0 source release now shows~> 1.0. The coordinated AshReplicant 1.0 release will require Replicant 1.x; that consumer dependency change lands separately. - Release hygiene. A
.tool-versionspins Elixir 1.20.3-otp-29 / Erlang 29.0.3; CI'ssetup-beamis aligned to it (it was otp-27 / elixir-1.17, and the formatter's list-wrap heuristic is version-sensitive —mix format --check-formattedwas red on the dev toolchain).mix audit(the declared-but-unenforceddeps.unlock --check-unused+hex.audit+deps.auditalias) is now a CI gate before build, and the cache key binds the pinned toolchain +mix.lock+.tool-versions. - Hex package boundary. Package files now enumerate the published docs and
ADR directories instead of including all of
docs/, so ignored local Forge specs, plans, reviews, and handoffs cannot leak into release bytes. Replicant.Config.tno longer advertises a:batchkey. It is derived fromcheckpoint_store[:batch]; a top-levelbatch:option is rejected with:config_invalid, so the public type advertising it was a trapdoor.%Transaction.changestyped as the union it is. WasEnumerable.t()(broad enough to hide that a spilled streamed txn delivers a single-passReplicant.Spill.Reader, not a re-iterable List); now[Change.t()] | Spill.Reader.t()with a strengthened moduledoc naming the forbidden calls (length/1,Enum.to_list/1, re-iteration) that force a spilled txn back into RAM.- Hex description states the delivery guarantee honestly. Was an unqualified "exactly-once delivery"; now "zero-loss delivery — exactly-once for transactional sinks, at-least-once (duplicate-bounded) for non-transactional sinks" (Critical Rule 3).
- The three vendored public functions are specced.
Casting.Types.cast_record/2,Casting.ArrayParser.parse/1,Decoder.OidDatabase.name_for_type_id/1now carry@spec; the frozen public surface is fully specced.
0.3.1 - 2026-07-14
Changed
- Docs. Documented the A6 command-error watchdog on every surface it was missing:
a "Resilience knobs" reference section in the getting-started Livebook (grouping
max_inflight_lag, checkpoint-store retry,max_command_retries, andfailover), and themax_command_retriesoption +[:connection, :command_error_halt]event inusage-rules.md. No library API change (the watchdog itself shipped in 0.3.0).
0.3.0 - 2026-07-14
Added
- Replication-command-error watchdog (
max_command_retries, default 5). A persistent pre-frame replication-command error (e.g.CREATE_REPLICATION_SLOTfailing because the server's replication slots are exhausted, or a slot already active for another consumer) previously reconnected forever viaauto_reconnect. The pipeline now halts fail-closed and stays idle aftermax_command_retriesfailed connect cycles without the stream establishing, emitting[:replicant, :connection, :command_error_halt](value-free metadata:attempt/max_retries/slot_name).max_command_retries: 0halts on the first fault. Transient outages that occur once the stream is flowing still self-heal (the counter resets on the first replication frame), and a down server keeps retrying untouched. The bound is a cycle count, not a wall-clock time. (Behavior change: persistent pre-frame command errors now halt instead of livelocking.)
0.2.2 - 2026-07-14
Added
- Getting-started Livebook.
notebooks/getting_started.livemd— a runnable, self-verifying interactive tour: it starts a live pipeline, streamsINSERT/UPDATE/DELETEthrough a small in-notebook sink, then demonstrates the unchanged-TOAST sentinel, transaction-granularity exactly-once, snapshot/backfill, and logical-decoding messages. Rendered on HexDocs (with a "Run in Livebook" badge) and shipped in the Hex package. Its code is executed against a live PG16/PG17 on every CI run (test/integration/livebook_getting_started_test.exs), so it can never drift from the library. No library API change.
0.2.1 - 2026-07-14
Changed
- Packaging:
AGENTS.mdis no longer included in the published Hex tarball — it is an agent-contract/meta file, not part of the library's public documentation. No code change.
0.2.0 - 2026-07-14
Added
- Idle-slot heartbeat / ack-advance. On a keepalive with zero transactions in flight, the
confirmed-flush LSN advances to
wal_end, so a quiet-but-filtered publication no longer pins WAL indefinitely (the #1 real-world logical-replication incident class). Always on, no knob; the advance is gated on a transaction-boundary predicate (no open transaction, no in-flight streamed txn, checkpoint ≥ last commit) so it can never ack past an undelivered transaction or message. - Incremental (resumable) initial snapshot.
snapshot: [mode: :incremental]chunks the backfill and persists a resume token, so a large snapshot survives a restart without re-copying from scratch. The streaming window drops any snapshot chunk row a concurrent change already superseded (convergence-safe, effect-once); PK-update, delete, and truncate all taint the drop-set correctly. - PostgreSQL 17+ forward-compatibility: reads the authoritative
invalidation_reasonslot column (pluswal_status/conflicting) on PG17+ for complete invalidation detection. - Opt-in
failover: truefor PG17 failover slots (HA resume on a promoted standby). Halts fail-closed{:config, :failover_unsupported}on PG16. - Fail-closed halt
{:slot_synced_unpromoted}when pointed at an unpromoted standby's synced slot. - GitHub Actions CI matrix testing PG16 and PG17.
- Multi-publication per pipeline.
publication:accepts a single validated name or a list (publication: ["p1", "p2"]) to stream the union of several publications through one slot. Every name is identifier-validated;start_replication/3and the four discovery queries bindDISTINCT ... pubname = ANY($1), andpublication_exists/1interpolates a validatedIN (...)list (the connect-chain simple-query protocol can't bind$1). A new connect-chain:publication_checkstep halts fail-closed if the found-pubnames set ≠ the requested set — aSTART_REPLICATIONthat names a missing publication would otherwise silently stream the subset. pgoutput de-dupes overlapping tables across publications on the wire. - Logical-decoding messages (
pg_logical_emit_message). Opt-in viamessages: true(the sink must implementhandle_message/2, else the pipeline is rejected at start as:messages_unsupportedrather than silently dropping messages later). The guarantee is stated honestly per message kind: a transactional message (transactional => true) rides%Transaction{messages: [...]}and is effect-once (inherits the txncommit_lsndedup); a non-transactional message routes tohandle_message/2and is at-least-once — duplicates possible on reconnect (no dedup key). Two durability seams prevent silent loss: the idle-acktrack_txnbump (§8.1 — a non-txn message in flight blocks the idle slot advance, so a keepalive cannot advanceconfirmed_flushpast an undelivered message) and the batch-boundary{:flush_before_message}seam (§8.4 — a non-txn message flushes an open sink-owned batch in delivery order). New%Message{}struct (transactional?,lsn,prefix,content,xid,ordinal) decoded by v1 + streamed clauses; themessagesflag threads through tostart_replication. A message'scontentandprefixare user bytes (Critical Rule 1: never logged or surfaced in telemetry).
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).