package dbdoor import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/carlosframework/platform/internal/dbdoor/dbdoortest" ) // addBatch inserts one more batch into src and replicates it into dir. func addBatch(t *testing.T, src, dir string, batch int) { t.Helper() db, err := sql.Open("sqlite", src) if err != nil { t.Fatal(err) } for i := 0; i < dbdoortest.Rows; i++ { if _, err := db.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("row-%d-%d", batch, i)); err != nil { db.Close() t.Fatal(err) } } db.Close() dbdoortest.Sync(t, src, dir) } // pause returns a time in a whole second of its own: after every file // written so far, and before the next one. RestoreAsOf rounds its time down // to the second, as S3's listing does. func pause() time.Time { time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond))) at := time.Now() time.Sleep(time.Until(at.Truncate(time.Second).Add(time.Second + 10*time.Millisecond))) return at } // A copy as of T holds what was replicated before T and nothing after it. func TestRestoreAsOfStopsAtTheTime(t *testing.T) { dir := t.TempDir() src := dbdoortest.Replicate(t, dir, 2) at := pause() addBatch(t, src, dir, 2) c := NewClient(newTestDoor(t, dir).do) latest := collect(t, c, 0, 0) got, err := RestoreAsOf(context.Background(), c, t.TempDir(), at) if err != nil { t.Fatal(err) } defer os.RemoveAll(got.Dir) if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows { t.Fatalf("copy as of %v holds %d rows, want %d (the third batch came after)", at, rows, 2*dbdoortest.Rows) } if got.TXID >= uint64(latest[len(latest)-1].MaxTXID) { t.Fatalf("TXID = %d, want one before the newest (%d)", got.TXID, latest[len(latest)-1].MaxTXID) } if !got.At.Before(at) { t.Fatalf("At = %v, want a time before the requested %v", got.At, at) } } // A time after the newest write is simply the newest copy. func TestRestoreAsOfAfterTheNewestWrite(t *testing.T) { dir := t.TempDir() dbdoortest.Replicate(t, dir, 2) got, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), pause()) if err != nil { t.Fatal(err) } defer os.RemoveAll(got.Dir) if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows { t.Fatalf("restored %d rows, want %d", rows, 2*dbdoortest.Rows) } } // A time before anything the replica keeps is its own answer, naming the // oldest point it does keep, and not "nothing replicated". func TestRestoreAsOfBeforeTheHistory(t *testing.T) { at := time.Now().Add(-time.Hour) dir := t.TempDir() dbdoortest.Replicate(t, dir, 2) parent := t.TempDir() _, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), parent, at) if !errors.Is(err, ErrBeforeHistory) { t.Fatalf("RestoreAsOf before the history = %v, want ErrBeforeHistory", err) } if errors.Is(err, ErrNothingReplicated) { t.Fatalf("RestoreAsOf before the history = %v, must not read as nothing replicated", err) } var bh *BeforeHistoryError if !errors.As(err, &bh) || bh.Oldest.IsZero() || !bh.Oldest.After(at) { t.Fatalf("RestoreAsOf before the history = %#v, want the oldest kept point, after %v", err, at) } assertEmptyDir(t, parent) // The point it names is one a person can ask for. got, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), bh.Oldest) if err != nil { t.Fatalf("RestoreAsOf at the named oldest point %v = %v, want a copy", bh.Oldest, err) } os.RemoveAll(got.Dir) } // An empty replica is still "nothing replicated", with or without a time. func TestRestoreAsOfEmptyReplica(t *testing.T) { parent := t.TempDir() _, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, t.TempDir()).do), parent, time.Now()) if !errors.Is(err, ErrNothingReplicated) { t.Fatalf("RestoreAsOf of an empty replica = %v, want ErrNothingReplicated", err) } assertEmptyDir(t, parent) } // A listing with no times (the claim-mode door sends none) must refuse: // litestream reads a zero time as "before T" and would hand back the // newest copy labelled as the requested one. func TestRestoreAsOfRefusesAListingWithoutTimes(t *testing.T) { dir := t.TempDir() dbdoortest.Replicate(t, dir, 2) untimed := relist(newTestDoor(t, dir), func(f *LTXFile) { f.CreatedAt = time.Time{} }) parent := t.TempDir() _, err := RestoreAsOf(context.Background(), NewClient(untimed), parent, time.Now()) if !errors.Is(err, ErrNoTimes) { t.Fatalf("RestoreAsOf over an untimed listing = %v, want ErrNoTimes", err) } assertEmptyDir(t, parent) // Without a time, the same listing restores as before. got, err := RestoreTo(context.Background(), NewClient(untimed), t.TempDir()) if err != nil { t.Fatalf("RestoreTo over an untimed listing = %v, want the newest copy", err) } os.RemoveAll(got.Dir) } // relist serves door's listings with each file passed through edit. func relist(door *testDoor, edit func(*LTXFile)) Doer { return func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { res, err := door.do(ctx, tail, h) if err != nil || res.StatusCode != http.StatusOK || h.Get("Range") != "" { return res, err } var list ListResponse if err := json.NewDecoder(res.Body).Decode(&list); err != nil { return nil, err } for i := range list.Files { edit(&list.Files[i]) } rec := httptest.NewRecorder() json.NewEncoder(rec).Encode(list) return rec.Result(), nil } } // S3 lists LastModified to the whole second, rounded down, so a file // uploaded just after T in the same second lists as before T. The copy must // still leave it out. func TestRestoreAsOfWithSecondResolutionTimes(t *testing.T) { dir := t.TempDir() src := dbdoortest.Replicate(t, dir, 2) // Start just after a whole second, so batch three lands in the same // second as T but after it. time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 100*time.Millisecond))) at := time.Now() time.Sleep(50 * time.Millisecond) addBatch(t, src, dir, 2) if done := time.Now(); done.Truncate(time.Second) != at.Truncate(time.Second) { t.Skipf("batch three took until %v, past T's second; the case needs it inside", done) } c := NewClient(relist(newTestDoor(t, dir), func(f *LTXFile) { f.CreatedAt = f.CreatedAt.Truncate(time.Second) })) got, err := RestoreAsOf(context.Background(), c, t.TempDir(), at) if err != nil { t.Fatal(err) } defer os.RemoveAll(got.Dir) if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows { t.Fatalf("copy as of %v holds %d rows, want %d: a write after T is in it", at, rows, 2*dbdoortest.Rows) } } // Go's zero time means "the newest copy" to RestoreAsOf and to litestream. // A requested time at or near it (year 1, or a fraction of a second into // it that rounds down to zero) must still be a time before the history, // never the newest copy. Found in Astra's review. The zero instant itself, // in any zone, is "the newest copy" by contract; parseDBAt keeps a typed // one from reaching here. func TestRestoreAsOfNeverTreatsAnEarlyTimeAsTheNewest(t *testing.T) { dir := t.TempDir() dbdoortest.Replicate(t, dir, 2) for _, at := range []time.Time{ time.Time{}.Add(500 * time.Millisecond), time.Time{}.Add(time.Hour), } { _, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), at) if !errors.Is(err, ErrBeforeHistory) { t.Errorf("RestoreAsOf(%v) = %v, want ErrBeforeHistory", at, err) } } }