| new file mode 100644 |
| index 000000000..4c8413a0f |
| --- /dev/null |
| +++ b/internal/hostbox/restore_wal_test.go |
| @@ -0,0 +1,114 @@ |
| +package hostbox |
| + |
| +import ( |
| + "database/sql" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/lease" |
| +) |
| + |
| +// walLTXFixture builds a snapshot whose database is in WAL mode, as every |
| +// database restored from a real litestream replica is (litestream requires |
| +// it). With tables false the schema is created and dropped again, so the |
| +// file is a valid WAL-mode database that the restore check refuses ("no |
| +// tables") after it has opened it. |
| +func walLTXFixture(t *testing.T, value string, tables bool) (string, []byte, uint64) { |
| + t.Helper() |
| + path := filepath.Join(t.TempDir(), "fixture.db") |
| + db, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + db.SetMaxOpenConns(1) |
| + stmts := []string{`PRAGMA journal_mode=WAL`, `CREATE TABLE fixture(value TEXT)`} |
| + if tables { |
| + stmts = append(stmts, `INSERT INTO fixture VALUES ('`+value+`')`) |
| + } else { |
| + stmts = append(stmts, `DROP TABLE fixture`) |
| + } |
| + for _, s := range stmts { |
| + if _, err := db.Exec(s); err != nil { |
| + t.Fatalf("%s: %v", s, err) |
| + } |
| + } |
| + if err := db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := mustRead(t, path) |
| + if len(raw) < 20 || raw[18] != 2 || raw[19] != 2 { |
| + t.Fatalf("fixture header says journal mode bytes %d,%d; want 2,2 (WAL)", raw[18], raw[19]) |
| + } |
| + return ltxFromDBFile(t, path, 9, 1, 1) |
| +} |
| + |
| +func restoreLeftovers(t *testing.T, dir string) []string { |
| + t.Helper() |
| + es, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []string |
| + for _, e := range es { |
| + if strings.HasPrefix(e.Name(), ".restore-") { |
| + out = append(out, e.Name()) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// A restored database is in WAL mode, so the read-only check attaches a -wal |
| +// and a -shm it cannot delete. Install must leave none of them behind, on the |
| +// way to success and on the way to a failed check, and must never touch the |
| +// .restore-* files of a concurrent restore. |
| +func TestColdRestoreLeavesNoRestoreTempFilesBehindWALDatabase(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + tables bool |
| + wantErr string |
| + }{ |
| + {name: "success", tables: true}, |
| + {name: "failed check", tables: false, wantErr: "check restored database"}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + dir := t.TempDir() |
| + db := filepath.Join(dir, "shop.db") |
| + key, snapshot, crc := walLTXFixture(t, "selected", tc.tables) |
| + rd := &restoreReader{data: map[string][]byte{key + "@v1": snapshot}} |
| + // Another restore's files, in flight beside ours. |
| + others := []string{".restore-other.db", ".restore-other.db-shm", ".restore-other.db-wal"} |
| + for _, o := range others { |
| + if err := os.WriteFile(filepath.Join(dir, o), []byte("not ours"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + err := (ColdRestore{Reader: rd, Bucket: "bucket", Generation: "one", Work: &restoreWork{}}).Install(t.Context(), db, |
| + &genselect.Plan{Generation: "one", MaxTXID: 1, Pin: &genselect.Pin{SnapshotCRC64: crc}, Files: []genselect.File{{Key: key, VersionID: "v1", Level: 9, MinTXID: 1, MaxTXID: 1, Size: int64(len(snapshot))}}}) |
| + if tc.wantErr == "" { |
| + if err != nil { |
| + t.Fatalf("Install: %v", err) |
| + } |
| + if err := lease.CheckDB(db); err != nil { |
| + t.Fatalf("installed database: %v", err) |
| + } |
| + // The query above may attach its own sidecars to the installed |
| + // database; they are named for it, not for the restore temp. |
| + } else { |
| + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { |
| + t.Fatalf("Install error = %v, want %q", err, tc.wantErr) |
| + } |
| + if _, err := os.Stat(db); !os.IsNotExist(err) { |
| + t.Fatalf("a refused restore left a database: %v", err) |
| + } |
| + } |
| + got := restoreLeftovers(t, dir) |
| + if strings.Join(got, ",") != strings.Join(others, ",") { |
| + t.Fatalf(".restore-* files = %v, want only the concurrent restore's %v", got, others) |
| + } |
| + }) |
| + } |
| +} |