package lease import ( "context" "crypto/sha256" "database/sql" "fmt" "io" "net/url" "os" "path/filepath" "testing" "time" lsfile "github.com/benbjohnson/litestream/file" "modernc.org/sqlite" ) func TestCheckerClosedCheckpointFinalizationProbe(t *testing.T) { backend := &lockStore{} h := localHeld(t, backend, 30*time.Second) rn := h.rn ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() path := filepath.Join(t.TempDir(), "source.db") seedDB(t, path) replica := t.TempDir() h.replicaClient = lsfile.NewReplicaClient(replica) receipt, err := h.Checkpoint(ctx, path) if err != nil { t.Fatal(err) } if receipt.CompletedAt.IsZero() || rn != h.rn { t.Fatal("checkpoint ownership") } for _, suffix := range []string{"-wal", "-shm"} { s, e := os.Stat(path + suffix) if e != nil { t.Fatalf("expected PERSIST_WAL sidecar%s: %v", suffix, e) } t.Logf("after Litestream Close%s size=%d", suffix, s.Size()) } u := url.URL{Scheme: "file", Path: path} q := u.Query() q.Set("mode", "rw") u.RawQuery = q.Encode() d, err := sql.Open("sqlite", u.String()) if err != nil { t.Fatal(err) } d.SetMaxOpenConns(1) d.SetMaxIdleConns(1) conn, err := d.Conn(ctx) if err != nil { t.Fatal(err) } if err = conn.Raw(func(raw any) error { f, ok := raw.(sqlite.FileControl) if !ok { return fmt.Errorf("no file control") } before, e := f.FileControlPersistWAL("main", -1) if e != nil { return e } t.Logf("ordinary PERSIST_WAL before explicit clear=%d", before) after, e := f.FileControlPersistWAL("main", 0) if e != nil { return e } if after != 0 { return fmt.Errorf("persist remains%d", after) } return nil }); err != nil { t.Fatal(err) } var integrity string if err = conn.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil || integrity != "ok" { t.Fatalf("integrity=%q err=%v", integrity, err) } var busy, log, done int if err = conn.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &log, &done); err != nil || busy != 0 || log != 0 || done != 0 { t.Fatalf("checkpoint %d %d %d err=%v", busy, log, done, err) } if err = conn.Close(); err != nil { t.Fatal(err) } if err = d.Close(); err != nil { t.Fatal(err) } absent := func() { t.Helper() for _, suffix := range []string{"-wal", "-shm", "-journal"} { if _, e := os.Lstat(path + suffix); !os.IsNotExist(e) { t.Fatalf("sidecar remains%s err=%v", suffix, e) } } } absent() if rn != h.rn { t.Fatal("renewer changed") } if _, err = h.SafeUntil(); err != nil { t.Fatal(err) } if _, deletes := backend.counts(); deletes != 0 { t.Fatal("lease released") } if peer, e := acquireTTL(ctx, "test-bucket", "one.example", "test-region", 30*time.Second); e == nil { peer.Release(ctx) t.Fatal("peer acquired") } pin, err := os.Open(path) if err != nil { t.Fatal(err) } defer pin.Close() beforeInfo, err := pin.Stat() if err != nil { t.Fatal(err) } hash := func() [32]byte { t.Helper() if _, e := pin.Seek(0, 0); e != nil { t.Fatal(e) } h := sha256.New() if _, e := io.Copy(h, pin); e != nil { t.Fatal(e) } var out [32]byte copy(out[:], h.Sum(nil)) return out } before := hash() q.Set("mode", "ro") q.Set("immutable", "1") u.RawQuery = q.Encode() reader, err := sql.Open("sqlite", u.String()) if err != nil { t.Fatal(err) } reader.SetMaxOpenConns(1) var count int if err = reader.QueryRowContext(ctx, "SELECT count(*) FROM t").Scan(&count); err != nil || count != 50 { t.Fatalf("immutable rows=%d err=%v", count, err) } if err = reader.Close(); err != nil { t.Fatal(err) } after := hash() afterInfo, err := pin.Stat() if err != nil { t.Fatal(err) } if before != after || !os.SameFile(beforeInfo, afterInfo) { t.Fatal("immutable check changed main") } absent() if count := restoreCount(t, replica, filepath.Join(t.TempDir(), "restored.db")); count != 50 { t.Fatalf("replica rows=%d", count) } if _, err = h.SafeUntil(); err != nil { t.Fatal(err) } if _, deletes := backend.counts(); deletes != 0 { t.Fatal("prep/check released lease") } t.Logf("PASS same Held, SQLite cleanup without unlink, immutable read preserves main SHA256=%x and absent sidecars; restored rows=50", after) }