Relationships
#3482 Datastore rework Phase 4 D2: restore, refresh and takeover from the v3 replica, with a checkout-local commits.db (dormant)
Opened by stack72 · 10/14/2026· Shipped 10/15/2026
Background (read this first)
swamp is reworking its datastore layer (tracking: swamp-club#2865). In the new format, v3, every change is a commit in a single-writer SQLite commits.db. Content is stored as content-addressed chunks, and each checkout rebuilds its files from the commits. v3 is opt-in today for filesystem datastores (swamp-club#3279), and swamp-uat's filesystem-v3 backend tests it in CI.
Phase 4 is "solo mode on buckets":
- the CLI is the single writer of a local
commits.db; - Litestream replicates it to the bucket;
- chunks go to the bucket through
storage/1; - a fenced writer lease makes sure only one machine writes.
Shipped so far:
- A, swamp-club#3357: replication cost, choosing an idle sidecar.
- B, swamp-club#3358: the pinned, hash-checked Litestream binary.
- C, swamp-club#3359: the fenced writer lease (
src/domain/datastore_v3/writer_lease.ts,src/infrastructure/datastore_v3/v3_writer_lock.ts). Every CLI write command takes a new fence, serve keeps one for its lifetime, andcommits.dbkeeps a store-widewriter_fencethat refuses lower fences. - D1, swamp-club#3416: a dormant Litestream sidecar (
src/infrastructure/datastore_v3/litestream/,src/domain/datastore_v3/litestream_replication.tsandreplication_lineage.ts).- A write command confirms its commits are replicated (
POST /syncwithwait,replicated_txid >= txid, then the lease check) before it succeeds. - Replication follows a lineage: the replica path is
_litestream/<fence>/, named by the fence at which this copy's replication began. Another writer's fence restarts it on a new path. The lineage is recorded inlineage.jsonbesidecommits.db, with abandoned ones inpreviousLineages. - Nothing replicates unless the internal
SWAMP_V3_REPLICAsetting (replica_setting.ts, tests only) names a file or S3 replica.
- A write command confirms its commits are replicated (
The decisions are in design/enablers/datastore-litestream.md ("Decision", "Restore, failover and fences (Q6)", "Phase 4D1") and in design/enablers/datastores.md (the "v3 …" sections).
Goal
Phase 4 D2: a v3 copy that isn't the replica's writer can get the datastore's commits from the replica, and a writer that takes over from another machine starts from them.
- Restore onto a fresh machine.
- Refresh a copy that is behind.
- Take over from another writer with the old writer fenced.
Ship it dormant, behind the same internal SWAMP_V3_REPLICA setting as D1. Phase 4E switches it on for S3 and GCS.
What's missing today
commits.dblives in the shared datastore directory. On a bucket each machine has its own. With replication on, nothing places a checkout-localcommits.db, so two "machines" can't be modelled.- The current lineage is known only locally.
lineage.jsonsits besidecommits.db, so another machine can't find which_litestream/<fence>/path holds the datastore's state. - Nothing restores. A copy with no
commits.db, or a stale one, has no way to fetch the replica's state. A writer that takes the lease after another machine wrote would write on top of a stale database. - Old lineage paths are never removed.
previousLineagesrecords them for later collection.
Design
- A checkout-local
commits.dbwhen replicating. WhileSWAMP_V3_REPLICAis set, each checkout keeps its owncommits.db(and Litestream's state andlineage.jsonbeside it) in the checkout's.swamp/, not in the shared datastore directory.- Chunks, the format marker and the lease stay in the datastore.
- Two checkouts on one filesystem-v3 datastore then behave like two machines on a bucket, which is what the tests use.
- Unset, nothing changes.
- This was listed under E; doing it here makes D2 testable end to end. Update E's plan entry.
- Publish the lineage in the datastore's control plane, next to the lease (
_control/, throughControlPlaneStore; on buckets that binding is swamp-club#3364).- Use create-only records, as the lease does, so two writers can never both claim a lineage.
- Record:
- that a lineage started: its start fence, holder and path;
- that its first sync was confirmed.
- "The current state" is the highest lineage with a confirmed sync.
- Record the scheme in
datastore-litestream.md.
- Restore when the local copy is missing or behind, before the command's first read or write:
- missing: restore the current lineage into a temp file, check it (
PRAGMA integrity_check, schema version), then rename it into place; - behind: Litestream 0.5 has no incremental restore outside follow mode, so a refresh is a full restore with
-forceinto a temp file, then a swap. - Decide and record how "behind" is detected cheaply, for example the current lineage record against the local
lineage.json, plus a last-confirmed TXID published with the lineage. A read of an up-to-date copy must cost no download. - Read-only commands refresh too, so reads on machine B see machine A's confirmed writes. Measure the cost of a refresh check and of a refresh at 1 MB and 100 MB.
- missing: restore the current lineage into a temp file, check it (
- Takeover. A writer whose lease fence restarts the lineage (another writer took a fence in between) restores the current lineage before it writes, then starts its own lineage at its fence, which needs a full first snapshot.
- Keep this ordering:
- the old writer: confirm the sync, then check the lease, then acknowledge;
- the new writer: take the lease, then restore.
- So every commit the old writer acknowledged is in what the new writer restores. A zombie's later uploads go to its own old path, which nothing restores from once a newer lineage is confirmed.
- Keep this ordering:
- Garbage-collect old lineage paths after the new lineage's first confirmed sync: the paths in
previousLineagesand lineages older than the current one. Never touch the current lineage, or one that isn't confirmed yet. - The first-clone budget. A small restore costs about 32 sequential round trips (Phase 4A). Report what a fresh machine pays before its first command, and keep the restore off the path when nothing changed.
- Windows stays unavailable while the pin is below 0.5.18.
Tests
- Integration, against a real Litestream with a file replica and two checkouts on one filesystem-v3 datastore (repeat-safe, no leaked processes):
- a fresh checkout restores and sees every acknowledged commit;
- a stale reader refreshes;
- an up-to-date reader downloads nothing;
- a takeover restores before writing, starts a new lineage, and keeps every commit the old writer acknowledged;
- a zombie (paused past its lease, resumed after a takeover) lands nothing in what the new writer or a fresh restore sees;
- a writer killed after its lineage record but before its first confirmed sync doesn't become "the current state";
- GC removes only abandoned paths.
- The opt-in S3 case against a local emulator, as D1 has it.
- Property or model-based tests (fast-check) for the lineage records: over random sequences of take, confirm, crash and take-over by two or three writers, the current state is always the latest confirmed lineage, and no acknowledged commit is missing from it.
- Dormancy: with
SWAMP_V3_REPLICAunset, nothing changes on filesystem-v3 and nothing is written to the control plane. v2 doesn't change; apply the v2 gate.
Out of scope
- E: turning it on for S3 and GCS, chunks through
storage/1, the marker (#3204) and binding the lease at the bucket root (#3364). - F: the outbox and the durability leftovers.
- Team mode's followers (
restore -fwarm standby) are Phase 5.
Blocked by: nothing (D1 has shipped). Runs in parallel with F.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.