ADR-0005 — Initial snapshot: cursor-gated suppression + brief per-chunk lock
Copy Markdown View SourceStatus: Accepted (user-approved 2026-07-21) · Extends: ADR-0002 (adds snapshot-path preconditions) · Honors, does not supersede: ADR-0001, ADR-0003, ADR-0004
Context
C2 backfills the rows that pre-exist a capstan pipeline's start, woven into the continuously-running
C1 stream so there is no handoff instant to get wrong — no gap and no duplicate at row
granularity, resumable mid-backfill. The source is a read-only MySQL replica (a payment switch's
replica, for the first design partner), so the mechanism may not write to the source, and MySQL has
no EXPORT_SNAPSHOT (Postgres's atomic snapshot-id export). The central constraint, forced live
on 8.0: there is no lock-free way to atomically pair a START TRANSACTION WITH CONSISTENT SNAPSHOT
read view with an exact GTID position — an in-transaction @@global.gtid_executed read returns the
live global, not the frozen view.
The obvious lock-free design — read a low watermark LW = @@gtid_executed, read a chunk, read a high
watermark HW, and reconcile the chunk against the window (LW, HW] — is not fail-closed. A
live probe on 8.0 confirmed that @@gtid_executed LEADS InnoDB row-visibility under concurrent
commit: a GTID enters gtid_executed before its row is visible to a new read view, by up to the
in-flight group-commit depth (300000/300000 iterations with 3 concurrent writers; 0/120000 with a
single writer). So a transaction gT touching a chunk key can be in LW (hence excluded from the
chunk's window, not dropped from the chunk) while the chunk SELECT still reads the STALE value —
and the stream, seeing gT ≤ LW, suppresses it. The stale chunk row then overwrites the fresh
streamed row: silent corruption of exactly the changed rows, the class C2 exists to prevent. For
a fail-closed-over-silent-corruption library (ADR-0001, "never a parity-shaped lie"), a rare silent
corruption is disqualifying, even though Debezium's read-only snapshot tolerates the same window.
The fix cannot be lock-free. A provable lower bound on a chunk's read view needs EITHER an in-band
write committed through the same path (DBLog's watermark — impossible on a read-only replica) OR a
brief lock that quiesces commits while an exact (gtid, snapshot-view) pair is captured. A live
investigation on a reproduced replica topology measured the brief LOCK TABLES … READ cheap
(~150–265 ms per chunk dominated by per-call connection setup; sub-ms on capstan's persistent
connection), needing only the LOCK TABLES privilege (not global RELOAD/FTWRL).
Decision
C2 backfills via cursor-gated suppression + a brief per-chunk LOCK TABLES … READ that captures an
exact GTID position G. The read-only lock-free (LW, HW] bracket is rejected (not
fail-closed).
Two authorities, never conflated. The GTID processed-watermark is advanced ONLY by the stream (exactly as C1). The snapshot advances a per-table PK cursor held in a separate
Capstan.SnapshotStore. The snapshot contributes zero GTIDs — it can never become aCapstan.Gtid.member?/2dedup floor, so ADR-0001's one-authoritative-position invariant is untouched. The stream runs continuously from a floorP0 = @@gtid_executedread strictly before the first chunk and pre-seeded into theCheckpointStore, so the dump and the assembler watermark both resolve toP0through the sanctionedstart_position: :checkpointpath — no C1b machinery and no ADR-0004 supersession (the pre-seed IS the documented "start from now" pattern). C1b (%Position{}override /:current) stays deferred and refused fail-closed.Brief-lock exact position
G(per chunk of tableT).SET SESSION lock_wait_timeout(a bounded MDL wait — the default is ~1 year) →LOCK TABLES T READ→G = @@gtid_executed→START TRANSACTION WITH CONSISTENT SNAPSHOT→UNLOCK TABLES→ chunkSELECT … WHERE pk > cursor ORDER BY pk LIMIT chunk_size(as-of the pinned view, lock-free) →COMMIT. The lock quiescedT's writes and waited for in-flight ones, soGis a provable lower bound on the chunk's view forT: noT-change≤ Gis missing from the chunk, and no newT-change commits during the capture. This is the guarantee the written watermark gives DBLog, obtained with a brief read lock instead of a write the replica forbids.Cursor-gate (strict-once). A
Snapshot.Coordinatorinterposes as theAssemblerServer's sink and its:watermark_observer. For each streamed row-image on a snapshot-active table it forwards iffk ≤ cursor(T)(already backfilled) orTis complete, else suppresses (its future chunk delivers it). A fully-suppressed transaction advances the watermark with no sink call (a filtered transaction, ADR-0003). A PK-changing UPDATE is split intodelete(k_old)+upsert(k_new), each gated on its own key. The advance gate emits a buffered chunk (as%Change{op: :snapshot}) onceGtid.subset?(G, processed_set), then advances the cursor and persists it. The chunk read usesLIMIT chunk_size + 1(a one-row finality look-ahead) so the last chunk carriesfinal_chunk?: trueeven when a table's row count is an exact multiple ofchunk_size(closeout F6); the look-ahead row is dropped and re-read as the head of the next chunk, so no row is skipped or duplicated.Sink surface. A new optional
Capstan.Sink.handle_snapshot/2delivers a chunk as a concrete list of%Capstan.Change{op: :snapshot}with a value-freeCapstan.Snapshot.Meta. It is distinct fromhandle_transaction/1because a chunk is not a committed transaction (no GTID, no atomicity) — routing it throughhandle_transaction/1would fabricate a GTID and break ADR-0001. Semantics are upsert-by-PK: a snapshot row may be superseded by a later streamed change, so a compliant sink applies each chunk row as an upsert. This is a HARD C2 precondition (the strict-once suppression and the bounded crash-window re-emit both rely on it to converge).Delivery guarantee. Strict-once in normal operation (the cursor-gate, not convergence). The only duplicate window is a crash between the chunk's sink
{:ok, _}and thepk_cursorpersist — the one in-flight chunk re-emits, exactly the bounded window C1 already accepts; an upsert-by-PK sink converges. Crash-window delete backstop (closeout F1): a second durable high-water,delivered_pk, is persisted BEFORE each emit (thepk_cursorafter). Across that same window it sits ahead of the rolled-backpk_cursor, and the cursor-gate gates DELETES on it — a streamed delete of an already-delivered key (k ≤ delivered_pk) is forwarded and sweeps the row, instead of being suppressed while the re-read chunk (taken as-of a freshGwith the row already gone) omits it, which would leave a permanent phantom. Inserts/updates keep gating onpk_cursor, so strict-once is preserved; a forwarded delete of a not-yet-delivered key is a harmless no-op at the upsert/delete-by-PK sink. Effect-once across the crash window is the deferred sink-owned atomic path (C1a).Choke-point observer. The
AssemblerServergains one optional:watermark_observernotified at the checkpoint choke point (do_checkpoint/3success), which fires on EVERY advance — delivered, filtered, AND self-committing DDL — by construction, so a DDL on a non-snapshot table can never stall the advance gate. No observer ⇒ byte-identical C1.Snapshot preconditions (an ADR-0002 addendum). The SELECT path adds its own fail-closed preconditions in ADR-0002's posture, each a distinct value-free halt:
- Order-faithful PK only. Chunking pages by MySQL
ORDER BY pkwhile the cursor-gate compares in Elixir, so the PK type's Elixir term-order must provably match MySQL's: signed/unsigned integer +BINARY/VARBINARY+ composites thereof. A collation-ordered STRING PK (CHAR/VARCHAR/TEXT) halts:snapshot_pk_unsupported_type(Elixir cannot reproduce a collation order — a silent gap/dup risk).BIGINT UNSIGNEDis in-allowlist only with a decoder yielding the true value across 2^63. A missing PK with a nullable unique-key column halts:snapshot_table_no_primary_key. LOCK TABLESprivilege on the snapshot tables (in addition toSELECT); noRELOAD/FTWRL.- Source identity —
@@server_uuidis compared across the stream and query connections, at connect and on every query-conn reconnect (:snapshot_source_mismatch), so a VIP reconnect to a different replica mid-backfill is caught. tables: :allsnapshot is refused (:config_invalid) — when the capture allowlist is itself:all, "snapshot every table on the server" is ambiguous and dangerous, so C2 requires an explicit snapshot table list. A concrete capture allowlist (from which snapshot defaults) works.- Config/state table-set reconciliation (closeout F3). On a
:complete/mid-snapshot resume the configuredsnapshot.tablesare reconciled against the stored%Snapshot.State{}table keys; a divergence (a table added or removed after a durable state exists) halts:snapshot_config_driftedrather than silently never backfilling an added table — both the:completeshort-circuit and the resume path key off the STORED set. Dropping the durable snapshot state re-introspects config.
- Order-faithful PK only. Chunking pages by MySQL
Rejected alternatives
- Read-only lock-free
(LW, HW]bracket — rejected: not fail-closed (the confirmedgtid_executed-leads-visibility silent corruption above). - C1b explicit-start machinery / an ADR-0004 supersession — rejected as unnecessary scope: the
P0pre-seed into theCheckpointStoremakes the dump and the watermark agree by construction. - Written-watermark (DBLog) path — impossible on a read-only replica; the brief read lock is the correct equivalent.
- A blanket composite-PK refusal — rejected: composite integer/binary PKs are order-faithful (tuple compare) and ship; only collation-ordered STRING columns are refused.
Consequences
- The snapshot→stream handoff is gap-free and dup-free at row granularity, proven live under
concurrent load, with the exact-
Glower bound the correctness linchpin (a baregtid_executedread is a red-capable regression). A silent corruption is not possible in normal operation. - The backfill costs a brief per-chunk table lock. With
replica_preserve_commit_order=ONa commit-head-blocked write stalls the whole parallel applier for the (sub-ms, bounded) hold, so a large table incurs some replication lag during backfill; a largerchunk_size(fewer locks) mitigates it. Negligible for a shadow pilot; the DBA should know. - New operational requirement: the query account needs
LOCK TABLESon the snapshot tables. Recorded in the preflight/DBA handout. - New public contract:
Capstan.Sink.handle_snapshot/2(optional; required only in snapshot mode) and%Capstan.Change{op: :snapshot}(upsert-by-PK). Additive — an absent:snapshotconfig is pure C1, byte-for-byte. - Two named fail-closed capability boundaries ship as loud refusals, each a separable follow-up:
collation-ordered-string PK (collation-aware cursor comparison) and
tables: :allsnapshot resolution (a scopedinformation_schemaenumeration). Neither is silent narrowing.
Evidence
- Probe (the rejected bracket):
@@gtid_executedleads visibility, 300000/300000 under 3 writers — the confirmed silent-corruption defect. Marquee:test/integration/snapshot_test.exs(no-gap/no-dup under 8-writer load, resume, suppression non-vacuity, advance gate under filtered+DDL). - Exact-
Glower bound:lib/capstan/snapshot/chunk_reader.ex+test/capstan/snapshot/chunk_reader_test.exs(live: 0 violations locked / violations≠[] bare — the red-capable regression gate). - Cursor-gate + PK-changing UPDATE split:
lib/capstan/snapshot/cursor_gate.ex. Order-faithful PK:lib/capstan/snapshot/primary_key.ex. Coordinator:lib/capstan/snapshot/coordinator.ex. BootstrapP0pre-seed + supervisor wiring:lib/capstan/snapshot.ex,lib/capstan/supervisor.ex. Query conn- source identity:
lib/capstan/query.ex. Choke-point hook:lib/capstan/assembler_server.ex.
- source identity:
- Halt catalog: the
:snapshot_*reasons inlib/capstan/error.ex.