carlos / platform Public

sim: litestreamfacts: exact assertions, anchor replay bytes, the anchored lineage through the Store (R6-2, R6-5)

Every fact is asserted exactly and fails on any alternative behaviour
(R6-2): the newest level-0 file retained by identity through TXID- and
time-based retention, the next TXID exactly N+1 after an anchored or an
ordinary restore, every row restored (a run that kept 7 of 9 fails), the
original continuation gone after the second rotation, the rooted restore
refused with litestream's own "non-contiguous ltx files" message for the
gap after TXID 1, and the newest restore's exact row count.

TestFact_R6_5_AnchorReplayBytes: the anchor's bytes are identical for the
same inputs and differ with the header timestamp, so a replayed PUT must
persist the timestamp or reuse the object (R6-5).

TestGate_AnchoredLineageThroughStore: one anchored lineage through
internal/lease's Store shape (metadata directory, replica client, the
compaction ladder, snapshot and L0 retention monitors) with every
interval shortened: three rounds of eight writes with level 1-3
compaction, snapshot rotation and time-based level-0 expiry running,
then disk loss and a cold restore; exact positions (every sync advances
by one, a reopened box continues at the newest remote level-0 TXID plus
one) and exact row counts. The package doc says what the file client
cannot show and that the repository has no S3 fake litestream can use.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 39ddcfbb4db2e5433d41a07fd6a5e17758475d10 parent f7f7c43
1 file changed, +202 −48
  • internal/sim/litestreamfacts/facts_test.go +202 −48
diff --git a/internal/sim/litestreamfacts/facts_test.go b/internal/sim/litestreamfacts/facts_test.go
index d3dc027e3..86cac14bf 100644
--- a/internal/sim/litestreamfacts/facts_test.go
+++ b/internal/sim/litestreamfacts/facts_test.go
@@ -1,10 +1,24 @@
// Package litestreamfacts establishes, by observation of the pinned
// libraries (litestream v0.5.14, superfly/ltx v0.5.1), the facts the
-// move-any-app spec's restore contract rests on (moves spec rev 7.6 §5.1;
-// joint round 5, R5-1 to R5-3). It is test-only and drives litestream
-// exactly as internal/lease does, with the file replica client the lease
-// tests use. Each test logs what it observed and asserts the fact the spec
-// relies on, so a library upgrade that changes the fact fails here first.
+// move-any-app spec's restore contract rests on (moves spec rev 7.7 §5.1;
+// joint rounds 5 and 6, R5-1 to R5-3, R6-2, R6-5). It is test-only and
+// drives litestream as internal/lease does, with the file replica client
+// the lease tests use. Each test asserts the exact fact the spec relies on
+// and fails on any alternative behaviour, so a library upgrade that
+// changes a fact fails here first (R6-2): a passing run is the contract,
+// not a record of one observation. TestGate_AnchoredLineageThroughStore
+// takes one anchored lineage through the production Store configuration
+// (internal/lease's Held.Replicate, intervals shortened) with repeated
+// writes, compaction, snapshot rotation, time-based level-0 expiry, disk
+// loss and repeated cold restores, asserting exact positions and contents.
+//
+// What the file client cannot show (R6 review): S3 listings order and age
+// objects by LastModified, the file client by the encoded LTX timestamp
+// kept as the file's mtime; S3 keeps versions and delete markers, the file
+// client replaces and deletes outright; S3 requests can complete late. The
+// repository has no S3 fake that litestream's client can talk to (the
+// lease tests' fakeMirror is a MirrorStore seam), so those remain the
+// model's (internal/sim/hostlife) and §15 step 8's.
//
// The observed facts are recorded in ~/.cache/nearest-region/litestream-facts.md.
package litestreamfacts
@@ -13,7 +27,6 @@ import (
"bytes"
"context"
"database/sql"
- "errors"
"fmt"
"io"
"os"
@@ -159,10 +172,12 @@ func decode(t *testing.T, client litestream.ReplicaClient, info *ltx.FileInfo) (
return dec.Header(), dec.Trailer()
}
-// anchorL0 re-encodes a snapshot [1,N] as a level-0 file [N,N] with every
-// page: the file litestream's position initialization fetches, so that
-// replication continues from N (R5-1, the remedy under test).
-func anchorL0(t *testing.T, from litestream.ReplicaClient, snap *ltx.FileInfo, to litestream.ReplicaClient) {
+// encodeAnchor re-encodes a snapshot [1,N] as a level-0 file [N,N] with
+// every page: the file litestream's position initialization fetches, so
+// that replication continues from N (R5-1, the remedy under test). The
+// header timestamp is an input (R6-5): with the same inputs the bytes are
+// identical, which is what lets a replayed PUT repeat the same object.
+func encodeAnchor(t *testing.T, from litestream.ReplicaClient, snap *ltx.FileInfo, ts int64) []byte {
t.Helper()
ctx := context.Background()
rc, err := from.OpenLTXFile(ctx, snap.Level, snap.MinTXID, snap.MaxTXID, 0, 0)
@@ -192,7 +207,7 @@ func anchorL0(t *testing.T, from litestream.ReplicaClient, snap *ltx.FileInfo, t
Commit: hdr.Commit,
MinTXID: snap.MaxTXID,
MaxTXID: snap.MaxTXID,
- Timestamp: time.Now().UnixMilli(),
+ Timestamp: ts,
WALOffset: litestream.WALHeaderSize,
}); err != nil {
t.Fatal(err)
@@ -215,11 +230,34 @@ func anchorL0(t *testing.T, from litestream.ReplicaClient, snap *ltx.FileInfo, t
if err := enc.Close(); err != nil {
t.Fatal(err)
}
- if _, err := to.WriteLTXFile(ctx, 0, snap.MaxTXID, snap.MaxTXID, &buf); err != nil {
+ return buf.Bytes()
+}
+
+// anchorL0 writes the anchor for snap into to.
+func anchorL0(t *testing.T, from litestream.ReplicaClient, snap *ltx.FileInfo, to litestream.ReplicaClient) {
+ t.Helper()
+ b := encodeAnchor(t, from, snap, time.Now().UnixMilli())
+ if _, err := to.WriteLTXFile(context.Background(), 0, snap.MaxTXID, snap.MaxTXID, bytes.NewReader(b)); err != nil {
t.Fatal(err)
}
}
+// mustRanges fails unless the level's current files are exactly want.
+func mustRanges(t *testing.T, client litestream.ReplicaClient, level int, want string) {
+ t.Helper()
+ if got := ranges(files(t, client, level)); got != want {
+ t.Fatalf("level %d: have %s, want exactly %s", level, got, want)
+ }
+}
+
+// mustCount fails unless the database at path holds exactly want rows.
+func mustCount(t *testing.T, path string, want int) {
+ t.Helper()
+ if n := count(t, path); n != want {
+ t.Fatalf("%s: %d rows, want exactly %d", filepath.Base(path), n, want)
+ }
+}
+
// source builds a replicated source database with n rows and a level-9
// snapshot of it, and returns the client and the snapshot's info.
func source(t *testing.T, n int) (litestream.ReplicaClient, *ltx.FileInfo) {
@@ -314,21 +352,17 @@ func TestFact_R5_1_AnchorL0ContinuesFromN(t *testing.T) {
sync(t, db)
closeDB(t, db)
l0 := files(t, gen, 0)
- t.Logf("FACT R5-1c: with a level-0 anchor [%d,%d] beside the snapshot, level 0 after two writes is %s", snap.MaxTXID, snap.MaxTXID, ranges(l0))
- last := l0[len(l0)-1]
- if last.MaxTXID <= snap.MaxTXID {
- t.Fatalf("expected replication to continue past %d, got %s", snap.MaxTXID, ranges(l0))
- }
+ t.Logf("FACT R5-1c: with a level-0 anchor [%d,%d] beside the snapshot, level 0 after two writes and one sync is %s", snap.MaxTXID, snap.MaxTXID, ranges(l0))
+ // exactly: the anchor, then the next TXID N+1 and nothing else (R6-2)
+ n := snap.MaxTXID
+ mustRanges(t, gen, 0, fmt.Sprintf("[%d,%d] [%d,%d]", n, n, n+1, n+1))
wipe(t, out, meta)
out2 := filepath.Join(dir, "out2.db")
if err := restore(t, gen, out2); err != nil {
t.Fatal(err)
}
- n := count(t, out2)
- t.Logf("FACT R5-1d: a cold restore after that holds %d rows of 7", n)
- if n != 7 {
- t.Fatalf("expected the new rows restored (7), got %d", n)
- }
+ t.Logf("FACT R5-1d: a cold restore after that holds %d rows of 7", count(t, out2))
+ mustCount(t, out2, 7)
}
// TestFact_R5_1_TXIDSidecarIsFollowOnly: a -txid sidecar beside the
@@ -357,12 +391,13 @@ func TestFact_R5_1_TXIDSidecarIsFollowOnly(t *testing.T) {
}
}
-// TestFact_R5_1_L0ExpiryAndAFreshBox: what a fresh box's position is after
+// TestFact_R5_1_L0ExpiryAndAFreshBox: a fresh box's position after
// litestream's own level-0 expiry. TXID-based retention keeps the newest
-// level-0 file; time-based retention (L0Retention, the store's monitor)
-// is the path that could remove it. The test runs the time-based path and
-// records what the next upload after a restore carries, and whether a
-// cold restore afterwards keeps the new writes.
+// level-0 file, and so does time-based retention (L0Retention, the store's
+// monitor); a fresh box restored from the replica therefore continues at
+// N+1 and a cold restore afterwards holds every row. Each of those is
+// asserted exactly (R6-2): a library that removed the newest level-0 file,
+// restarted numbering or lost the new writes fails here.
func TestFact_R5_1_L0ExpiryAndAFreshBox(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "src.db")
@@ -384,7 +419,12 @@ func TestFact_R5_1_L0ExpiryAndAFreshBox(t *testing.T) {
if err := db.EnforceRetentionByTXID(context.Background(), 0, snap.MaxTXID+1); err != nil {
t.Fatal(err)
}
+ n := snap.MaxTXID // the newest level-0 file's TXID, the position
+ newest := fmt.Sprintf("[%d,%d]", n, n)
byTXID := ranges(files(t, client, 0))
+ if byTXID != newest {
+ t.Fatalf("TXID-based retention at %d left level 0 %s, want exactly the newest file %s retained", n+1, byTXID, newest)
+ }
// time-based level-0 expiry, as the store's L0 retention monitor runs it
db.L0Retention = time.Millisecond
time.Sleep(20 * time.Millisecond)
@@ -392,29 +432,27 @@ func TestFact_R5_1_L0ExpiryAndAFreshBox(t *testing.T) {
t.Fatal(err)
}
closeDB(t, db)
- t.Logf("FACT R5-1f: after TXID-based level-0 retention at %d the level-0 listing is %s; after time-based L0 expiry it is %s (L9 %s; L1 %s)", snap.MaxTXID+1, byTXID, ranges(files(t, client, 0)), ranges(files(t, client, 9)), ranges(files(t, client, 1)))
+ t.Logf("FACT R5-1f: after TXID-based level-0 retention at %d the level-0 listing is %s; after time-based L0 expiry it is %s (L9 %s; L1 %s)", n+1, byTXID, ranges(files(t, client, 0)), ranges(files(t, client, 9)), ranges(files(t, client, 1)))
+ mustRanges(t, client, 0, newest) // the newest level-0 file survives time-based expiry too, by identity
out := filepath.Join(dir, "out.db")
if err := restore(t, client, out); err != nil {
t.Fatal(err)
}
+ mustCount(t, out, 7)
meta := filepath.Join(dir, "meta2")
db2 := openDB(t, out, meta, client)
write(t, out, 2)
sync(t, db2)
closeDB(t, db2)
- l0 := files(t, client, 0)
- t.Logf("FACT R5-1g: on a fresh box restored at TXID %d, the first upload is level 0 %s", snap.MaxTXID, ranges(l0))
+ t.Logf("FACT R5-1g: on a fresh box restored at TXID %d, the first upload is level 0 %s", n, ranges(files(t, client, 0)))
+ mustRanges(t, client, 0, fmt.Sprintf("%s [%d,%d]", newest, n+1, n+1)) // exactly N+1: a restart at 1 fails here
wipe(t, out, meta)
out2 := filepath.Join(dir, "out2.db")
if err := restore(t, client, out2); err != nil {
t.Fatal(err)
}
- n := count(t, out2)
- t.Logf("FACT R5-1h: a cold restore after that holds %d rows of 9", n)
- restarted := len(l0) > 0 && l0[0].MinTXID == 1
- if restarted && n != 7 || !restarted && n != 9 {
- t.Fatalf("inconsistent: restarted=%v rows=%d", restarted, n)
- }
+ t.Logf("FACT R5-1h: a cold restore after that holds %d rows of 9", count(t, out2))
+ mustCount(t, out2, 9) // every row: a run that kept 7 of 9 fails here
}
// TestFact_R5_2_Checksums: what the pinned writers put in LTX headers and
@@ -524,7 +562,10 @@ func TestFact_R5_3_SnapshotRetention(t *testing.T) {
}
return false
}
- t.Logf("FACT R5-3c: after one retention pass a continuation from the original snapshot [1,%d] remains: %v (the level-1 file that starts at 1 is above minSnapshotTXID)", snap1.MaxTXID, continuation())
+ t.Logf("FACT R5-3c: after one retention pass a continuation from the original snapshot [1,%d] remains: %v (the level-1 file that starts at 1 ends at minSnapshotTXID and deletion is strictly below)", snap1.MaxTXID, continuation())
+ if !continuation() {
+ t.Fatalf("expected the level-1 file starting at 1 to survive the first pass (it ends at minSnapshotTXID %d)", minSnap)
+ }
// one more rotation: the level-1 file starting at 1 now ends below the
// new minSnapshotTXID and is expired
for i := 0; i < 3; i++ {
@@ -550,6 +591,10 @@ func TestFact_R5_3_SnapshotRetention(t *testing.T) {
after2 := fmt.Sprintf("L9 %s; L1 %s; L0 %s", ranges(files(t, client, 9)), ranges(files(t, client, 1)), ranges(files(t, client, 0)))
cont := continuation()
t.Logf("FACT R5-3f: after a second rotation and retention (minSnapshotTXID %d): %s; a continuation from the original snapshot remains: %v", minSnap, after2, cont)
+ if cont {
+ t.Fatalf("expected every continuation from the original snapshot [1,%d] to be expired after the second rotation", snap1.MaxTXID)
+ }
+ mustRanges(t, client, 9, fmt.Sprintf("[1,%d]", snap2.MaxTXID)) // only the newest snapshot
// restore rooted at the original snapshot: the original's bytes plus the
// current lower levels, without the newer snapshot
rooted := lsfile.NewReplicaClient(filepath.Join(dir, "rooted"))
@@ -564,20 +609,129 @@ func TestFact_R5_3_SnapshotRetention(t *testing.T) {
errRooted := restore(t, rooted, filepath.Join(dir, "rooted.db"))
t.Logf("FACT R5-3d: restore rooted at the original snapshot after retention: %v", errRooted)
errNewest := restore(t, client, filepath.Join(dir, "newest.db"))
- t.Logf("FACT R5-3e: restore from the newest snapshot: %v (rows %d)", errNewest, func() int {
- if errNewest != nil {
- return -1
- }
- return count(t, filepath.Join(dir, "newest.db"))
- }())
if errNewest != nil {
t.Fatal(errNewest)
}
- if cont {
- if errRooted != nil {
- t.Fatalf("a continuation remained but the rooted restore failed: %v", errRooted)
+ t.Logf("FACT R5-3e: restore from the newest snapshot: ok (rows %d)", count(t, filepath.Join(dir, "newest.db")))
+ mustCount(t, filepath.Join(dir, "newest.db"), 19) // 1 row, then 3 rounds of 3 syncs of 2 rows
+ // the rooted restore fails closed, with litestream's own error (R6-2)
+ want := fmt.Sprintf("non-contiguous ltx files: have up to %016x but next file starts at %016x", uint64(snap1.MaxTXID), 5)
+ if errRooted == nil || !strings.Contains(errRooted.Error(), want) {
+ t.Fatalf("expected the rooted restore to fail closed with %q, got %v", want, errRooted)
+ }
+}
+
+// TestFact_R6_5_AnchorReplayBytes: the anchor's bytes depend on the header
+// timestamp. Encoded twice with the same inputs the bytes are identical;
+// with another timestamp they differ, so a builder that replays the anchor
+// PUT (a restarted install) must persist the timestamp it used, or reuse
+// the object it already wrote (moves spec §5.1 step 2, §7.6 building).
+func TestFact_R6_5_AnchorReplayBytes(t *testing.T) {
+ src, snap := source(t, 5)
+ ts := time.Now().UnixMilli()
+ a := encodeAnchor(t, src, snap, ts)
+ b := encodeAnchor(t, src, snap, ts)
+ c := encodeAnchor(t, src, snap, ts+1)
+ t.Logf("FACT R6-5: anchor [%d,%d], %d bytes; same timestamp: identical=%v; timestamp+1 ms: identical=%v", snap.MaxTXID, snap.MaxTXID, len(a), bytes.Equal(a, b), bytes.Equal(a, c))
+ if !bytes.Equal(a, b) {
+ t.Fatalf("the same inputs produced different anchor bytes")
+ }
+ if bytes.Equal(a, c) {
+ t.Fatalf("a different timestamp produced identical bytes: the premise of persisting it is gone, review the contract")
+ }
+ // the replayed object is accepted by the decoder and equals the first
+ dec := ltx.NewDecoder(bytes.NewReader(b))
+ if err := dec.Verify(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestGate_AnchoredLineageThroughStore: one anchored lineage through the
+// production Store configuration (internal/lease Held.Replicate: a DB with
+// its own metadata directory, a replica client, litestream.NewStore with
+// the compaction ladder, the L0 retention monitor), with every interval
+// shortened from hours to fractions of a second so the monitors run inside
+// the test: repeated writes, level-1 to level-3 compaction, snapshot
+// rotation and retention, time-based level-0 expiry, then disk loss and a
+// cold restore, three times over. Exact positions (every sync advances the
+// position by one, and a reopened box continues at exactly the newest
+// remote level-0 TXID plus one) and exact row counts are asserted.
+func TestGate_AnchoredLineageThroughStore(t *testing.T) {
+ src, snap := source(t, 5)
+ dir := t.TempDir()
+ gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
+ copyFile(t, src, gen, snap)
+ anchorL0(t, src, snap, gen)
+ path := filepath.Join(dir, "db")
+ if err := restore(t, gen, path); err != nil {
+ t.Fatal(err)
+ }
+ rows := 5
+ pos := int(snap.MaxTXID)
+ mustCount(t, path, rows)
+ // internal/lease's ladder is L1 1h, L2 6h, L3 24h, the snapshot interval
+ // 24h, L0 retention 5 min checked hourly, the DB monitor 10 s: here each
+ // is shortened by the same shape
+ levels := litestream.CompactionLevels{
+ {Level: 0, Interval: 0},
+ {Level: 1, Interval: 150 * time.Millisecond},
+ {Level: 2, Interval: 400 * time.Millisecond},
+ {Level: 3, Interval: 900 * time.Millisecond},
+ }
+ ctx := context.Background()
+ for round := 0; round < 3; round++ {
+ meta := filepath.Join(dir, fmt.Sprintf("meta%d", round))
+ db := litestream.NewDB(path)
+ db.SetMetaPath(meta)
+ db.MonitorInterval = 50 * time.Millisecond
+ db.Replica = litestream.NewReplicaWithClient(db, gen)
+ db.Replica.SyncInterval = 50 * time.Millisecond
+ store := litestream.NewStore([]*litestream.DB{db}, levels)
+ store.SnapshotInterval = 300 * time.Millisecond
+ store.SnapshotRetention = 400 * time.Millisecond
+ store.SetL0Retention(200 * time.Millisecond) // NewStore copied the default into the DB; SetL0Retention propagates
+ store.L0RetentionCheckInterval = 50 * time.Millisecond
+ if err := store.Open(ctx); err != nil {
+ t.Fatalf("round %d: open store: %v", round, err)
+ }
+ l0Before := len(files(t, gen, 0))
+ for i := 0; i < 8; i++ {
+ write(t, path, 1)
+ rows++
+ sync(t, db)
+ pos++
+ p, err := db.Pos()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if int(p.TXID) != pos {
+ t.Fatalf("round %d write %d: position %d, want exactly %d (a reopened box continues at the newest remote level-0 TXID plus one)", round, i, p.TXID, pos)
+ }
+ time.Sleep(120 * time.Millisecond) // the monitors run between writes
+ }
+ time.Sleep(700 * time.Millisecond) // compaction, rotation, retention, L0 expiry catch up
+ if err := store.Close(ctx); err != nil {
+ t.Fatal(err)
+ }
+ l0, l1, l9 := files(t, gen, 0), files(t, gen, 1), files(t, gen, 9)
+ t.Logf("GATE round %d: position %d, rows %d; L0 %s (%d files, %d before); L1 %s; L2 %s; L3 %s; L9 %s", round, pos, rows, ranges(l0), len(l0), l0Before, ranges(l1), ranges(files(t, gen, 2)), ranges(files(t, gen, 3)), ranges(l9))
+ if len(l0) == 0 || int(l0[len(l0)-1].MaxTXID) != pos {
+ t.Fatalf("round %d: newest remote level-0 file is %s, want MaxTXID exactly %d", round, ranges(l0), pos)
+ }
+ if len(l0) >= l0Before+8 {
+ t.Fatalf("round %d: time-based level-0 expiry removed nothing: %d files", round, len(l0))
+ }
+ if len(l1) == 0 {
+ t.Fatalf("round %d: no level-1 compaction ran", round)
+ }
+ if len(l9) == 0 || int(l9[len(l9)-1].MaxTXID) <= int(snap.MaxTXID) {
+ t.Fatalf("round %d: no snapshot rotated past the installed one: L9 %s", round, ranges(l9))
+ }
+ // disk loss, cold restore: exact contents
+ wipe(t, path, meta)
+ if err := restore(t, gen, path); err != nil {
+ t.Fatalf("round %d: cold restore: %v", round, err)
}
- } else if errRooted == nil || !(strings.Contains(errRooted.Error(), "non-contiguous") || errors.Is(errRooted, litestream.ErrTxNotAvailable)) {
- t.Fatalf("expected the rooted restore to fail closed on the gap, got %v", errRooted)
+ mustCount(t, path, rows)
}
}