carlos / platform Public

sim: litestreamfacts: the pinned library's replication, checksum and retention facts (R5-1 to R5-3)

Test-only, driving litestream v0.5.14 with the file replica client as
internal/lease does. Each test asserts a fact the moves spec's restore
contract rests on: a snapshot-only install restarts replication at TXID
1 and a cold restore loses the writes; a level-0 anchor [N,N] beside the
snapshot makes the pinned library continue at N+1; the -txid sidecar is
follow-mode only; retention keeps the newest level-0 file, so an
ordinary replica needs no anchor; every pinned writer sets
HeaderFlagNoChecksum and PostApplyChecksum is 0 (per-file CRC-64 only);
snapshot retention expires the continuation from an old snapshot after
two rotations, so a restore rooted there fails closed while one from the
newest snapshot succeeds. Facts recorded in
~/.cache/nearest-region/litestream-facts.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d96f781cf339a5dd08c8a52cfa2492af9b1ec1ef parent cdc9884
1 file changed, +583 −0
  • internal/sim/litestreamfacts/facts_test.go +583 −0
diff --git a/internal/sim/litestreamfacts/facts_test.go b/internal/sim/litestreamfacts/facts_test.go
new file mode 100644
index 000000000..d3dc027e3
--- /dev/null
+++ b/internal/sim/litestreamfacts/facts_test.go
@@ -0,0 +1,583 @@
+// 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.
+//
+// The observed facts are recorded in ~/.cache/nearest-region/litestream-facts.md.
+package litestreamfacts
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/benbjohnson/litestream"
+ lsfile "github.com/benbjohnson/litestream/file"
+ "github.com/superfly/ltx"
+ _ "modernc.org/sqlite"
+)
+
+// openDB opens path under litestream against client, with its own metadata
+// directory, no background monitors (every sync is explicit), and returns
+// the DB. The caller closes it.
+func openDB(t *testing.T, path, meta string, client litestream.ReplicaClient) *litestream.DB {
+ t.Helper()
+ db := litestream.NewDB(path)
+ db.SetMetaPath(meta)
+ db.MonitorInterval = time.Hour
+ db.Replica = litestream.NewReplicaWithClient(db, client)
+ db.Replica.SyncInterval = time.Hour
+ if err := db.Open(); err != nil {
+ t.Fatalf("open %s: %v", path, err)
+ }
+ return db
+}
+
+func closeDB(t *testing.T, db *litestream.DB) {
+ t.Helper()
+ if err := db.Close(context.Background()); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+}
+
+// write inserts n rows, each in its own transaction, so each is a
+// transaction litestream can see.
+func write(t *testing.T, path string, n int) {
+ t.Helper()
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < n; i++ {
+ if _, err := d.Exec(`INSERT INTO t (body) VALUES (?)`, strings.Repeat("x", 100)); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func count(t *testing.T, path string) int {
+ t.Helper()
+ d, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ var n int
+ if err := d.QueryRow(`SELECT count(*) FROM t`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+func sync(t *testing.T, db *litestream.DB) {
+ t.Helper()
+ if err := db.SyncAndWait(context.Background()); err != nil {
+ t.Fatalf("sync: %v", err)
+ }
+}
+
+// files lists one level of a replica, in litestream's order.
+func files(t *testing.T, client litestream.ReplicaClient, level int) []*ltx.FileInfo {
+ t.Helper()
+ itr, err := client.LTXFiles(context.Background(), level, 0, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ infos, err := ltx.SliceFileIterator(itr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return infos
+}
+
+func ranges(infos []*ltx.FileInfo) string {
+ var out []string
+ for _, f := range infos {
+ out = append(out, fmt.Sprintf("[%d,%d]", f.MinTXID, f.MaxTXID))
+ }
+ if len(out) == 0 {
+ return "none"
+ }
+ return strings.Join(out, " ")
+}
+
+// copyFile copies one LTX file between replica clients, as the install's
+// CopyObject would.
+func copyFile(t *testing.T, from, to litestream.ReplicaClient, info *ltx.FileInfo) {
+ t.Helper()
+ ctx := context.Background()
+ rc, err := from.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rc.Close()
+ if _, err := to.WriteLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, rc); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// restore restores client's newest state to out, litestream's own planner.
+func restore(t *testing.T, client litestream.ReplicaClient, out string) error {
+ t.Helper()
+ r := litestream.NewReplicaWithClient(nil, client)
+ opt := litestream.NewRestoreOptions()
+ opt.OutputPath = out
+ return r.Restore(context.Background(), opt)
+}
+
+// decode reads an LTX file's header and trailer and verifies it.
+func decode(t *testing.T, client litestream.ReplicaClient, info *ltx.FileInfo) (ltx.Header, ltx.Trailer) {
+ t.Helper()
+ rc, err := client.OpenLTXFile(context.Background(), info.Level, info.MinTXID, info.MaxTXID, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rc.Close()
+ b, err := io.ReadAll(rc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dec := ltx.NewDecoder(bytes.NewReader(b))
+ if err := dec.Verify(); err != nil {
+ t.Fatalf("verify %s: %v", ltx.FormatFilename(info.MinTXID, info.MaxTXID), err)
+ }
+ 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) {
+ t.Helper()
+ ctx := context.Background()
+ rc, err := from.OpenLTXFile(ctx, snap.Level, snap.MinTXID, snap.MaxTXID, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rc.Close()
+ dec := ltx.NewDecoder(rc)
+ if err := dec.DecodeHeader(); err != nil {
+ t.Fatal(err)
+ }
+ hdr := dec.Header()
+ var buf bytes.Buffer
+ enc, err := ltx.NewEncoder(&buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // WALOffset at the WAL header: litestream's verify reads the position
+ // from the newest local level-0 file and, finding the WAL does not
+ // match it, takes a fresh snapshot at the next TXID; an offset below
+ // the header size is refused outright ("prev WAL offset is less than
+ // the header size", observed)
+ if err := enc.EncodeHeader(ltx.Header{
+ Version: ltx.Version,
+ Flags: ltx.HeaderFlagNoChecksum,
+ PageSize: hdr.PageSize,
+ Commit: hdr.Commit,
+ MinTXID: snap.MaxTXID,
+ MaxTXID: snap.MaxTXID,
+ Timestamp: time.Now().UnixMilli(),
+ WALOffset: litestream.WALHeaderSize,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ page := make([]byte, hdr.PageSize)
+ for {
+ var ph ltx.PageHeader
+ if err := dec.DecodePage(&ph, page); err == io.EOF {
+ break
+ } else if err != nil {
+ t.Fatal(err)
+ }
+ if err := enc.EncodePage(ph, page); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := dec.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := enc.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := to.WriteLTXFile(ctx, 0, snap.MaxTXID, snap.MaxTXID, &buf); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// 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) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "src.db")
+ write(t, path, 1)
+ client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
+ db := openDB(t, path, filepath.Join(dir, "meta"), client)
+ sync(t, db)
+ write(t, path, n-1)
+ sync(t, db)
+ snap, err := db.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ closeDB(t, db)
+ t.Logf("source: L0 %s; snapshot [%d,%d]", ranges(files(t, client, 0)), snap.MinTXID, snap.MaxTXID)
+ return client, snap
+}
+
+// wipe removes the local database, its WAL and SHM, and litestream's
+// metadata: a lost disk.
+func wipe(t *testing.T, path, meta string) {
+ t.Helper()
+ for _, p := range []string{path, path + "-wal", path + "-shm", path + "-journal"} {
+ os.Remove(p)
+ }
+ if err := os.RemoveAll(meta); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestFact_R5_1_SnapshotOnlyInstallRestartsAtOne: install only the level-9
+// snapshot [1,N], restore it, start replication with fresh metadata and no
+// remote level 0. The next upload carries TXID 1, not N+1, and a cold
+// restore afterwards loses the new writes.
+func TestFact_R5_1_SnapshotOnlyInstallRestartsAtOne(t *testing.T) {
+ src, snap := source(t, 5)
+ dir := t.TempDir()
+ gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
+ copyFile(t, src, gen, snap)
+ out := filepath.Join(dir, "out.db")
+ if err := restore(t, gen, out); err != nil {
+ t.Fatal(err)
+ }
+ if n := count(t, out); n != 5 {
+ t.Fatalf("restored %d rows, want 5", n)
+ }
+ meta := filepath.Join(dir, "meta")
+ db := openDB(t, out, meta, gen)
+ write(t, out, 2)
+ sync(t, db)
+ closeDB(t, db)
+ l0 := files(t, gen, 0)
+ t.Logf("FACT R5-1a: after a snapshot-only install (snapshot [1,%d]) the first upload is level 0 %s", snap.MaxTXID, ranges(l0))
+ if len(l0) == 0 || l0[0].MinTXID != 1 {
+ t.Fatalf("expected replication to restart at TXID 1, got %s", ranges(l0))
+ }
+ 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-1b: a cold restore after that holds %d rows of 7: the planner roots at the snapshot and skips level-0 files below its TXID", n)
+ if n != 5 {
+ t.Fatalf("expected the two new rows to be lost (5 rows), got %d", n)
+ }
+}
+
+// TestFact_R5_1_AnchorL0ContinuesFromN: the remedy. The install also writes
+// a level-0 file [N,N] with every page. Litestream's open fetches the
+// newest remote level-0 file as its local position, so the next upload
+// carries N+1 and a cold restore has the new writes.
+func TestFact_R5_1_AnchorL0ContinuesFromN(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)
+ out := filepath.Join(dir, "out.db")
+ if err := restore(t, gen, out); err != nil {
+ t.Fatal(err)
+ }
+ if n := count(t, out); n != 5 {
+ t.Fatalf("restored %d rows, want 5", n)
+ }
+ meta := filepath.Join(dir, "meta")
+ db := openDB(t, out, meta, gen)
+ write(t, out, 2)
+ 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))
+ }
+ 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)
+ }
+}
+
+// TestFact_R5_1_TXIDSidecarIsFollowOnly: a -txid sidecar beside the
+// restored database does not set the replication position of an
+// ordinary open.
+func TestFact_R5_1_TXIDSidecarIsFollowOnly(t *testing.T) {
+ src, snap := source(t, 5)
+ dir := t.TempDir()
+ gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
+ copyFile(t, src, gen, snap)
+ out := filepath.Join(dir, "out.db")
+ if err := restore(t, gen, out); err != nil {
+ t.Fatal(err)
+ }
+ if err := litestream.WriteTXIDFile(out, snap.MaxTXID); err != nil {
+ t.Fatal(err)
+ }
+ db := openDB(t, out, filepath.Join(dir, "meta"), gen)
+ write(t, out, 1)
+ sync(t, db)
+ closeDB(t, db)
+ l0 := files(t, gen, 0)
+ t.Logf("FACT R5-1e: with a -txid sidecar of %d, the first upload is level 0 %s", snap.MaxTXID, ranges(l0))
+ if len(l0) == 0 || l0[0].MinTXID != 1 {
+ t.Fatalf("the sidecar unexpectedly set the position: %s", ranges(l0))
+ }
+}
+
+// TestFact_R5_1_L0ExpiryAndAFreshBox: what a fresh box's position is 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.
+func TestFact_R5_1_L0ExpiryAndAFreshBox(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "src.db")
+ write(t, path, 1)
+ client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
+ db := openDB(t, path, filepath.Join(dir, "meta"), client)
+ for i := 0; i < 3; i++ {
+ write(t, path, 2)
+ sync(t, db)
+ }
+ if _, err := db.Compact(context.Background(), 1); err != nil {
+ t.Fatal(err)
+ }
+ snap, err := db.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // TXID-based retention at the snapshot level
+ if err := db.EnforceRetentionByTXID(context.Background(), 0, snap.MaxTXID+1); err != nil {
+ t.Fatal(err)
+ }
+ byTXID := ranges(files(t, client, 0))
+ // time-based level-0 expiry, as the store's L0 retention monitor runs it
+ db.L0Retention = time.Millisecond
+ time.Sleep(20 * time.Millisecond)
+ if err := db.EnforceL0RetentionByTime(context.Background()); err != nil {
+ 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)))
+ out := filepath.Join(dir, "out.db")
+ if err := restore(t, client, out); err != nil {
+ t.Fatal(err)
+ }
+ 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))
+ 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)
+ }
+}
+
+// TestFact_R5_2_Checksums: what the pinned writers put in LTX headers and
+// trailers: the level-0 writer, the snapshot writer and the compactor.
+func TestFact_R5_2_Checksums(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "src.db")
+ write(t, path, 1)
+ client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
+ db := openDB(t, path, filepath.Join(dir, "meta"), client)
+ for i := 0; i < 3; i++ {
+ write(t, path, 2)
+ sync(t, db)
+ }
+ snap, err := db.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ l1, err := db.Compact(context.Background(), 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ closeDB(t, db)
+ check := func(name string, info *ltx.FileInfo) {
+ hdr, tr := decode(t, client, info)
+ t.Logf("FACT R5-2 %s [%d,%d]: HeaderFlagNoChecksum=%v PreApplyChecksum=%#x PostApplyChecksum=%#x FileChecksum!=0=%v (Verify passed: CRC-64 of the file)",
+ name, info.MinTXID, info.MaxTXID, hdr.NoChecksum(), uint64(hdr.PreApplyChecksum), uint64(tr.PostApplyChecksum), tr.FileChecksum != 0)
+ if !hdr.NoChecksum() || tr.PostApplyChecksum != 0 || tr.FileChecksum == 0 {
+ t.Fatalf("%s: unexpected checksum fields", name)
+ }
+ }
+ l0 := files(t, client, 0)
+ check("level 0", l0[len(l0)-1])
+ check("snapshot", snap)
+ check("level 1 (compactor)", l1)
+}
+
+// TestFact_R5_3_SnapshotRetention: newer snapshots appear, and snapshot
+// retention expires older snapshots and the lower-level ranges below the
+// oldest remaining one. Afterwards no current file continues from the
+// original snapshot's TXID, so a restore rooted there finds a gap, while
+// litestream's own restore from the newest snapshot succeeds.
+func TestFact_R5_3_SnapshotRetention(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "src.db")
+ write(t, path, 1)
+ client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
+ db := openDB(t, path, filepath.Join(dir, "meta"), client)
+ sync(t, db)
+ snap1, err := db.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // keep the original snapshot's bytes: on S3 it would be a pinned,
+ // noncurrent version; the file client deletes outright
+ rc, err := client.OpenLTXFile(context.Background(), 9, snap1.MinTXID, snap1.MaxTXID, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ snap1Bytes, _ := io.ReadAll(rc)
+ rc.Close()
+ // two more rotations: writes, a level-1 compaction, a snapshot, as the
+ // store's monitors would over days
+ var snap2 *ltx.FileInfo
+ for round := 0; round < 2; round++ {
+ for i := 0; i < 3; i++ {
+ write(t, path, 2)
+ sync(t, db)
+ }
+ if _, err := db.Compact(context.Background(), 1); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(20 * time.Millisecond)
+ if snap2, err = db.Snapshot(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before := fmt.Sprintf("L9 %s; L1 %s; L0 %s", ranges(files(t, client, 9)), ranges(files(t, client, 1)), ranges(files(t, client, 0)))
+ // snapshot retention keeps the newest snapshot; minSnapshotTXID is the
+ // MaxTXID of the snapshot before the oldest kept one, and the store
+ // then expires levels 1 and up below it (level 0 expires by time,
+ // L0Retention, 5 minutes by default: here it is enforced by TXID too)
+ minSnap, err := db.EnforceSnapshotRetention(context.Background(), time.Now().Add(time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.EnforceRetentionByTXID(context.Background(), 1, minSnap); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.EnforceRetentionByTXID(context.Background(), 0, minSnap); err != nil {
+ t.Fatal(err)
+ }
+ after := fmt.Sprintf("L9 %s; L1 %s; L0 %s", ranges(files(t, client, 9)), ranges(files(t, client, 1)), ranges(files(t, client, 0)))
+ t.Logf("FACT R5-3a: before retention: %s", before)
+ t.Logf("FACT R5-3b: after snapshot retention (minSnapshotTXID %d): %s", minSnap, after)
+ if len(files(t, client, 9)) != 1 || files(t, client, 9)[0].MaxTXID != snap2.MaxTXID {
+ t.Fatalf("expected only the newest snapshot to remain")
+ }
+ continuation := func() bool {
+ // any current file with MinTXID <= snap1.MaxTXID+1 and MaxTXID > snap1.MaxTXID
+ for _, lvl := range []int{0, 1, 2, 3} {
+ for _, f := range files(t, client, lvl) {
+ if f.MinTXID <= snap1.MaxTXID+1 && f.MaxTXID > snap1.MaxTXID {
+ return true
+ }
+ }
+ }
+ 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())
+ // 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++ {
+ write(t, path, 2)
+ sync(t, db)
+ }
+ if _, err := db.Compact(context.Background(), 1); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(20 * time.Millisecond)
+ if snap2, err = db.Snapshot(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if minSnap, err = db.EnforceSnapshotRetention(context.Background(), time.Now().Add(time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ for _, lvl := range []int{1, 0} {
+ if err := db.EnforceRetentionByTXID(context.Background(), lvl, minSnap); err != nil {
+ t.Fatal(err)
+ }
+ }
+ closeDB(t, db)
+ 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)
+ // 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"))
+ if _, err := rooted.WriteLTXFile(context.Background(), 9, snap1.MinTXID, snap1.MaxTXID, bytes.NewReader(snap1Bytes)); err != nil {
+ t.Fatal(err)
+ }
+ for _, lvl := range []int{0, 1, 2, 3} {
+ for _, f := range files(t, client, lvl) {
+ copyFile(t, client, rooted, f)
+ }
+ }
+ 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)
+ }
+ } 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)
+ }
+}