| new file mode 100644 |
| index 000000000..6677fd6c4 |
| --- /dev/null |
| +++ b/internal/dbdoor/plan_test.go |
| @@ -0,0 +1,221 @@ |
| +package dbdoor |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/benbjohnson/litestream" |
| + lsfile "github.com/benbjohnson/litestream/file" |
| + "github.com/superfly/ltx" |
| + |
| + "github.com/carlosframework/platform/internal/dbdoor/dbdoortest" |
| +) |
| + |
| +func TestPlanRestoreSizesTheRestoreExactly(t *testing.T) { |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 4) |
| + plan, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, src).do), 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err := RestoreToTarget(context.Background(), NewClient(newTestDoor(t, src).do), t.TempDir(), 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(r.Dir) |
| + st, err := os.Stat(r.Path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if plan.RestoredBytes != st.Size() || plan.TXID != r.TXID || plan.LTXBytes <= 0 || plan.Files == 0 || plan.Target != 0 { |
| + t.Fatalf("plan %+v, restored %d bytes at %d; want the plan's size and txid", plan, st.Size(), r.TXID) |
| + } |
| +} |
| + |
| +func TestRestoreToATargetTXID(t *testing.T) { |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 4) |
| + files := collect(t, NewClient(newTestDoor(t, src).do), 0, 0) |
| + at := uint64(files[0].MaxTXID) |
| + r, err := RestoreToTarget(context.Background(), NewClient(newTestDoor(t, src).do), t.TempDir(), at) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(r.Dir) |
| + if r.TXID != at { |
| + t.Fatalf("restored to %d, want the first file's end %d", r.TXID, at) |
| + } |
| + if rows := countRows(t, r.Path); rows != dbdoortest.Rows { |
| + t.Fatalf("rows = %d, want one batch's %d", rows, dbdoortest.Rows) |
| + } |
| +} |
| + |
| +// A target at or past the latest transaction plans the latest copy, and |
| +// says so with Target 0: litestream itself refuses a target past the end. |
| +func TestPlanRestoreTargetPastTheEndPlansTheLatest(t *testing.T) { |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 3) |
| + latest, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, src).do), 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, target := range []uint64{latest.TXID, latest.TXID + 100} { |
| + got, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, src).do), target) |
| + if err != nil { |
| + t.Fatalf("target %d: %v", target, err) |
| + } |
| + if got != latest { |
| + t.Fatalf("target %d planned %+v, want the latest %+v", target, got, latest) |
| + } |
| + } |
| +} |
| + |
| +// A planned TXID pins the restore: after the source moves on, a restore at |
| +// plan.TXID still produces exactly the planned size, transaction and rows. |
| +// Task 5 restores at plan.TXID for that reason (preflight ruling M2). |
| +func TestRestorePinnedAtAPlannedTXIDKeepsThePlannedSize(t *testing.T) { |
| + dir := t.TempDir() |
| + db := dbdoortest.Replicate(t, dir, 2) |
| + plan, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, dir).do), 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The source grows by many pages and replicates again. |
| + grow(t, db) |
| + dbdoortest.Sync(t, db, dir) |
| + later, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, dir).do), 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if later.TXID <= plan.TXID || later.RestoredBytes <= plan.RestoredBytes { |
| + t.Fatalf("the source did not move on: planned %+v, later %+v", plan, later) |
| + } |
| + |
| + r, err := RestoreToTarget(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), plan.TXID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(r.Dir) |
| + st, err := os.Stat(r.Path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.Size() != plan.RestoredBytes || r.TXID != plan.TXID { |
| + t.Fatalf("pinned restore: %d bytes at %d, want the plan's %d bytes at %d", st.Size(), r.TXID, plan.RestoredBytes, plan.TXID) |
| + } |
| + if rows := countRows(t, r.Path); rows != 2*dbdoortest.Rows { |
| + t.Fatalf("rows = %d, want the planned %d", rows, 2*dbdoortest.Rows) |
| + } |
| +} |
| + |
| +// Retention keeps a snapshot and the L0 files after it. A target inside |
| +// the snapshot's span is history that is no longer kept, while the latest |
| +// copy (and a target at the snapshot's own end) still restores. |
| +func TestPlanRestoreTargetBeforeHistory(t *testing.T) { |
| + src := t.TempDir() |
| + dbdoortest.Replicate(t, src, 4) |
| + files := collect(t, NewClient(newTestDoor(t, src).do), 0, 0) |
| + if len(files) < 4 { |
| + t.Fatalf("want one L0 file per batch, got %d", len(files)) |
| + } |
| + snap := snapshotThrough(t, src, files[:2]) |
| + for _, f := range files[:2] { |
| + if err := os.Remove(litestream.LTXFilePath(src, 0, f.MinTXID, f.MaxTXID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + latest, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, src).do), 0) |
| + if err != nil { |
| + t.Fatalf("the latest plan over a snapshot and later L0 files: %v", err) |
| + } |
| + if latest.TXID != uint64(files[len(files)-1].MaxTXID) || latest.Files != 1+len(files)-2 { |
| + t.Fatalf("latest plan %+v, want the snapshot plus %d L0 files ending at %d", latest, len(files)-2, files[len(files)-1].MaxTXID) |
| + } |
| + r, err := RestoreToTarget(context.Background(), NewClient(newTestDoor(t, src).do), t.TempDir(), latest.TXID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(r.Dir) |
| + if rows := countRows(t, r.Path); rows != 4*dbdoortest.Rows { |
| + t.Fatalf("rows = %d, want every batch's %d", rows, 4*dbdoortest.Rows) |
| + } |
| + |
| + atSnap, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, src).do), uint64(snap.MaxTXID)) |
| + if err != nil || atSnap.TXID != uint64(snap.MaxTXID) || atSnap.Files != 1 || atSnap.Target != uint64(snap.MaxTXID) { |
| + t.Fatalf("a target at the snapshot's end: plan %+v, err %v; want the snapshot alone", atSnap, err) |
| + } |
| + |
| + _, err = PlanRestore(context.Background(), NewClient(newTestDoor(t, src).do), uint64(files[0].MaxTXID)) |
| + if !errors.Is(err, ErrHistoryGone) || errors.Is(err, ErrNothingReplicated) { |
| + t.Fatalf("a target before the retained history: err = %v, want ErrHistoryGone", err) |
| + } |
| + _, err = RestoreToTarget(context.Background(), NewClient(newTestDoor(t, src).do), t.TempDir(), uint64(files[0].MaxTXID)) |
| + if !errors.Is(err, ErrHistoryGone) || errors.Is(err, ErrNothingReplicated) { |
| + t.Fatalf("a restore before the retained history: err = %v, want ErrHistoryGone", err) |
| + } |
| + |
| + if _, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, t.TempDir()).do), 0); !errors.Is(err, ErrNothingReplicated) { |
| + t.Fatalf("an empty replica: err = %v, want ErrNothingReplicated", err) |
| + } |
| + if _, err := PlanRestore(context.Background(), NewClient(newTestDoor(t, t.TempDir()).do), 5); !errors.Is(err, ErrNothingReplicated) { |
| + t.Fatalf("an empty replica with a target: err = %v, want ErrNothingReplicated", err) |
| + } |
| +} |
| + |
| +// snapshotThrough compacts the given L0 files, which must start at TXID 1, |
| +// into one snapshot-level file in the file replica at dir, the way |
| +// litestream's own compactor writes a level: ltx.Compactor over the source |
| +// files, written through the file client. |
| +func snapshotThrough(t *testing.T, dir string, files []*ltx.FileInfo) *ltx.FileInfo { |
| + t.Helper() |
| + ctx := context.Background() |
| + rc := lsfile.NewReplicaClient(dir) |
| + var rdrs []io.Reader |
| + for _, f := range files { |
| + r, err := rc.OpenLTXFile(ctx, 0, f.MinTXID, f.MaxTXID, 0, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + rdrs = append(rdrs, r) |
| + } |
| + pr, pw := io.Pipe() |
| + go func() { |
| + c, err := ltx.NewCompactor(pw, rdrs) |
| + if err != nil { |
| + pw.CloseWithError(err) |
| + return |
| + } |
| + c.HeaderFlags = ltx.HeaderFlagNoChecksum |
| + pw.CloseWithError(c.Compact(ctx)) |
| + }() |
| + info, err := rc.WriteLTXFile(ctx, litestream.SnapshotLevel, files[0].MinTXID, files[len(files)-1].MaxTXID, pr) |
| + pr.CloseWithError(err) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return info |
| +} |
| + |
| +// grow adds enough rows to the source database at path to add many pages. |
| +func grow(t *testing.T, path string) { |
| + t.Helper() |
| + db, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer db.Close() |
| + for i := 0; i < 50; i++ { |
| + if _, err := db.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("grow-%d-%s", i, strings.Repeat("x", 2000))); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |