carlos db pull/query --at: read a database as of a past time (#262)
--at takes an RFC 3339 time with a zone, refuses a time in the future, and reaches dbdoor.RestoreAsOf. The as-of time is rounded down to its second before planning: S3 lists LastModified at second resolution, rounded down, so without it a write made just after --at in the same second got into the copy. A time before the kept history names the first second that works. Strings approved in copy review 2026-10-08, unedited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human
Prompt
Yes, go ahead.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Adds --at to carlos db pull and query, with the second-resolution fix in dbdoor.
Agent notes
The e2e through the real console door uses the fake S3's second-resolution LastModified; a mutated row-count assertion confirmed it tells 20 rows from 30.
5 files changed,
+235
−30
db.go+64 −6db_at_test.go+68 −0db_e2e_test.go+35 −1internal/dbdoor/restore.go+13 −6internal/dbdoor/restore_asof_test.go+55 −17
CI cancelled โ superseded by a newer push — run details
diff --git a/db.go b/db.go| index 8efc220a4..12c4bb267 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -45,6 +45,11 @@ const dbUsage = `usage: carlos db <verb> --app <a> [--instance <host>] [flags] |
| query --sql <text> restore a copy and run one read-only query against it; |
| rows go to stdout, tab-separated, with a header row |
| +Add --at <time> to either verb to read the database as it was at that |
| +time, for example --at 2026-10-07T14:00:00Z. The copy holds every change |
| +replicated before then, so it can be a little older than the time you give. |
| +How far back you can go depends on how much history the platform keeps. |
| + |
| Both read the platform's replica of the database, never the live one, and |
| nothing you do to the copy reaches the instance. --instance names which of |
| the app's instances to read; it may be left out when the app has one. The |
| @@ -76,6 +81,9 @@ type dbTarget struct { |
| acct apiAcct |
| app string |
| host string |
| + // at is --at: the copy holds what was replicated before it. Zero is |
| + // the newest copy. |
| + at time.Time |
| } |
| // dbFlags registers the flags both verbs share and returns the resolver. |
| @@ -86,7 +94,14 @@ func dbFlags(fs *flag.FlagSet, verb string) func() dbTarget { |
| instance := fs.String("instance", "", "the instance whose database to read (may be left out when the app has one)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| + atRaw := fs.String("at", "", "read the database as it was at this time (RFC 3339, for example 2026-10-07T14:00:00Z)") |
| return func() dbTarget { |
| + // Before any network: a time that can't be read is the person's |
| + // to fix, and costs nothing to refuse. |
| + at, err := parseDBAt(*atRaw, time.Now()) |
| + if err != nil { |
| + log.Fatalf("carlos db %s: %v", verb, err) |
| + } |
| sess := requireDBAPISession(*consoleFlag) |
| printUsing(os.Stderr, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| @@ -95,8 +110,28 @@ func dbFlags(fs *flag.FlagSet, verb string) func() dbTarget { |
| if err != nil { |
| log.Fatalf("carlos db %s: %v", verb, err) |
| } |
| - return dbTarget{sess: sess, acct: acct, app: name, host: host} |
| + return dbTarget{sess: sess, acct: acct, app: name, host: host, at: at} |
| + } |
| +} |
| + |
| +// parseDBAt reads --at. Empty is the zero time, the newest copy. A time |
| +// must carry its zone: a bare clock time would be read in whichever zone |
| +// this shell is in, and the copy would silently be hours off. A time after |
| +// now is refused rather than quietly answered with the newest copy, so |
| +// nobody holds a copy labelled with a moment that hasn't happened. |
| +func parseDBAt(raw string, now time.Time) (time.Time, error) { |
| + raw = strings.TrimSpace(raw) |
| + if raw == "" { |
| + return time.Time{}, nil |
| + } |
| + at, err := time.Parse(time.RFC3339, raw) |
| + if err != nil { |
| + return time.Time{}, fmt.Errorf("--at %q is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC", raw) |
| } |
| + if at.After(now) { |
| + return time.Time{}, fmt.Errorf("--at %s is in the future. Leave --at out to read the newest copy", raw) |
| + } |
| + return at, nil |
| } |
| // requireDBAPISession resolves signed-in mode, refusing direct bucket mode |
| @@ -146,7 +181,7 @@ func resolveDBInstance(ctx context.Context, sess *apiSession, acct apiAcct, app, |
| // dbRestore restores the target's replica into a private directory under |
| // the system temp dir. The caller removes Restored.Dir. |
| func dbRestore(ctx context.Context, t dbTarget) (*dbdoor.Restored, error) { |
| - r, err := dbdoor.RestoreTo(ctx, t.sess.dbClient(t.acct, t.app, t.host), "") |
| + r, err := dbdoor.RestoreAsOf(ctx, t.sess.dbClient(t.acct, t.app, t.host), "", t.at) |
| if err != nil { |
| return nil, dbRestoreError(t.host, err) |
| } |
| @@ -158,7 +193,12 @@ func dbRestore(ctx context.Context, t dbTarget) (*dbdoor.Restored, error) { |
| // named here so none of them reads as a transient "no replica found". |
| func dbRestoreError(host string, err error) error { |
| var door *dbdoor.DoorError |
| + var before *dbdoor.BeforeHistoryError |
| switch { |
| + case errors.As(err, &before): |
| + return fmt.Errorf("%s's replica does not go back that far. The oldest point it keeps is %s", host, dbTime(before.Oldest)) |
| + case errors.Is(err, dbdoor.ErrNoTimes): |
| + return fmt.Errorf("%s's replica can't be read as of a time yet. Leave --at out to read the newest copy", host) |
| case errors.As(err, &door) && door.Status == 404: |
| return fmt.Errorf("no instance %s that you can read", host) |
| case errors.As(err, &door): |
| @@ -181,7 +221,26 @@ func dbAsOf(r *dbdoor.Restored) string { |
| if r.At.IsZero() { |
| return fmt.Sprintf("transaction %d", r.TXID) |
| } |
| - return fmt.Sprintf("transaction %d, written %s", r.TXID, r.At.UTC().Format("2006-01-02 15:04:05 UTC")) |
| + return fmt.Sprintf("transaction %d, written %s", r.TXID, dbTime(r.At)) |
| +} |
| + |
| +func dbTime(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") } |
| + |
| +// dbPullHolds is pull's line saying what the copy holds; with --at it also |
| +// names the requested time, since the copy can be older than it. |
| +func dbPullHolds(r *dbdoor.Restored, at time.Time) string { |
| + if at.IsZero() { |
| + return fmt.Sprintf("It holds every change up to %s.\n", dbAsOf(r)) |
| + } |
| + return fmt.Sprintf("It holds every change up to %s, the last one replicated before %s.\n", dbAsOf(r), dbTime(at)) |
| +} |
| + |
| +// dbQueryStatus is query's stderr line before the rows. |
| +func dbQueryStatus(host string, r *dbdoor.Restored, at time.Time) string { |
| + if at.IsZero() { |
| + return fmt.Sprintf("Querying a copy of %s's database, up to %s. Nothing you run here reaches %s.\n", host, dbAsOf(r), host) |
| + } |
| + return fmt.Sprintf("Querying a copy of %s's database as it was before %s, up to %s. Nothing you run here reaches %s.\n", host, dbTime(at), dbAsOf(r), host) |
| } |
| func runDBPull(args []string) { |
| @@ -219,7 +278,7 @@ func dbPull(ctx context.Context, t dbTarget, out string) error { |
| return err |
| } |
| fmt.Fprintf(stdout, "Saved a copy of %s's database to %s.\n", t.host, out) |
| - fmt.Fprintf(stdout, "It holds every change up to %s.\n", dbAsOf(r)) |
| + fmt.Fprint(stdout, dbPullHolds(r, t.at)) |
| fmt.Fprintf(stdout, "It is a copy. Nothing you do to it reaches %s.\n", t.host) |
| return nil |
| } |
| @@ -312,8 +371,7 @@ func dbQuery(ctx context.Context, t dbTarget, sqlText string) error { |
| return err |
| } |
| defer os.RemoveAll(r.Dir) |
| - fmt.Fprintf(os.Stderr, "Querying a copy of %s's database, up to %s. Nothing you run here reaches %s.\n", |
| - t.host, dbAsOf(r), t.host) |
| + fmt.Fprint(os.Stderr, dbQueryStatus(t.host, r, t.at)) |
| return runQueryContext(ctx, stdout, r.Path, sqlText) |
| } |
diff --git a/db_at_test.go b/db_at_test.go| new file mode 100644 |
| index 000000000..ecb0acd59 |
| --- /dev/null |
| +++ b/db_at_test.go |
| @@ -0,0 +1,68 @@ |
| +package main |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dbdoor" |
| +) |
| + |
| +func TestParseDBAt(t *testing.T) { |
| + now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) |
| + for _, ok := range []string{"2026-10-07T14:00:00Z", "2026-10-07T15:00:00+01:00", "2026-10-07T14:00:00.5Z"} { |
| + got, err := parseDBAt(ok, now) |
| + if err != nil { |
| + t.Errorf("parseDBAt(%q) = %v, want a time", ok, err) |
| + } else if !got.Before(now) { |
| + t.Errorf("parseDBAt(%q) = %v, want before %v", ok, got, now) |
| + } |
| + } |
| + if got, _ := parseDBAt("", now); !got.IsZero() { |
| + t.Errorf("parseDBAt(\"\") = %v, want zero (the newest copy)", got) |
| + } |
| + bad := map[string]string{ |
| + "2026-10-07 14:00": `--at "2026-10-07 14:00" is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC`, |
| + "2026-10-07T14:00:00": `--at "2026-10-07T14:00:00" is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC`, |
| + "yesterday": `--at "yesterday" is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC`, |
| + "2026-10-09T00:00:00Z": `--at 2026-10-09T00:00:00Z is in the future. Leave --at out to read the newest copy`, |
| + } |
| + for in, want := range bad { |
| + if _, err := parseDBAt(in, now); err == nil || err.Error() != want { |
| + t.Errorf("parseDBAt(%q) = %v, want %q", in, err, want) |
| + } |
| + } |
| +} |
| + |
| +func TestDBRestoreErrorsForAnAsOfRead(t *testing.T) { |
| + oldest := time.Date(2026, 10, 1, 9, 12, 44, 0, time.UTC) |
| + cases := map[error]string{ |
| + &dbdoor.BeforeHistoryError{Oldest: oldest}: "a.go.tito.io's replica does not go back that far. The oldest point it keeps is 2026-10-01 09:12:44 UTC", |
| + dbdoor.ErrNoTimes: "a.go.tito.io's replica can't be read as of a time yet. Leave --at out to read the newest copy", |
| + } |
| + for err, want := range cases { |
| + if got := dbRestoreError("a.go.tito.io", err).Error(); got != want { |
| + t.Errorf("%v -> %q, want %q", err, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestDBAsOfLines(t *testing.T) { |
| + r := &dbdoor.Restored{TXID: 42, At: time.Date(2026, 10, 7, 13, 58, 2, 0, time.UTC)} |
| + at := time.Date(2026, 10, 7, 15, 0, 0, 0, time.FixedZone("", 3600)) |
| + if got, want := dbPullHolds(r, at), "It holds every change up to transaction 42, written 2026-10-07 13:58:02 UTC, the last one replicated before 2026-10-07 14:00:00 UTC.\n"; got != want { |
| + t.Errorf("pull with --at:\n got %q\nwant %q", got, want) |
| + } |
| + if got, want := dbPullHolds(r, time.Time{}), "It holds every change up to transaction 42, written 2026-10-07 13:58:02 UTC.\n"; got != want { |
| + t.Errorf("pull without --at:\n got %q\nwant %q", got, want) |
| + } |
| + if got, want := dbQueryStatus("a.go.tito.io", r, at), "Querying a copy of a.go.tito.io's database as it was before 2026-10-07 14:00:00 UTC, up to transaction 42, written 2026-10-07 13:58:02 UTC. Nothing you run here reaches a.go.tito.io.\n"; got != want { |
| + t.Errorf("query with --at:\n got %q\nwant %q", got, want) |
| + } |
| + if got, want := dbQueryStatus("a.go.tito.io", r, time.Time{}), "Querying a copy of a.go.tito.io's database, up to transaction 42, written 2026-10-07 13:58:02 UTC. Nothing you run here reaches a.go.tito.io.\n"; got != want { |
| + t.Errorf("query without --at:\n got %q\nwant %q", got, want) |
| + } |
| + if !strings.Contains(dbUsage, "Add --at <time> to either verb") { |
| + t.Error("dbUsage does not explain --at") |
| + } |
| +} |
diff --git a/db_e2e_test.go b/db_e2e_test.go| index 29fad228b..2c8d08b41 100644 |
| --- a/db_e2e_test.go |
| +++ b/db_e2e_test.go |
| @@ -9,8 +9,10 @@ package main |
| import ( |
| "bytes" |
| "context" |
| + "database/sql" |
| "encoding/json" |
| "encoding/xml" |
| + "fmt" |
| "io/fs" |
| "net/http" |
| "net/http/httptest" |
| @@ -123,7 +125,26 @@ func TestE2EDBThroughConsole(t *testing.T) { |
| tmp := t.TempDir() |
| const host = "acme.go.tito.io" |
| replicaRoot := filepath.Join(tmp, "data") |
| - dbdoortest.Replicate(t, filepath.Join(replicaRoot, "instances", host), 3) |
| + // Two batches, then a third in a later whole second, so --at can |
| + // name a time between them in the fake S3's second-resolution listing. |
| + replicaDir := filepath.Join(replicaRoot, "instances", host) |
| + src := dbdoortest.Replicate(t, replicaDir, 2) |
| + time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond))) |
| + between := time.Now() |
| + time.Sleep(time.Until(between.Truncate(time.Second).Add(time.Second + 10*time.Millisecond))) |
| + { |
| + 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-2-%d", i)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + db.Close() |
| + dbdoortest.Sync(t, src, replicaDir) |
| + } |
| // lock.json sits under the same prefix in production. Nothing may fetch it. |
| if err := os.WriteFile(filepath.Join(replicaRoot, "instances", host, "lock.json"), []byte(`{"owner":"box"}`), 0o600); err != nil { |
| t.Fatal(err) |
| @@ -220,6 +241,19 @@ func TestE2EDBThroughConsole(t *testing.T) { |
| t.Fatalf("pulled copy = %q %v", rows.String(), err) |
| } |
| + // pull --at: the copy as of a time between the second and third |
| + // batches holds the first two, and says which time it was asked for. |
| + outAt := filepath.Join(t.TempDir(), "pulled-at.db") |
| + buf.Reset() |
| + runDB([]string{"pull", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--out", outAt, "--at", between.UTC().Format(time.RFC3339Nano)}) |
| + if want := "the last one replicated before " + dbTime(between); !strings.Contains(buf.String(), want) { |
| + t.Errorf("pull --at said %q, want it to mention %q", buf.String(), want) |
| + } |
| + rows.Reset() |
| + if err := runQuery(&rows, outAt, "SELECT count(*) AS n FROM t"); err != nil || rows.String() != "n\n"+strconv.Itoa(2*dbdoortest.Rows)+"\n" { |
| + t.Fatalf("copy as of %v = %q %v, want %d rows", between, rows.String(), err, 2*dbdoortest.Rows) |
| + } |
| + |
| // query: rows on stdout, nothing else. |
| buf.Reset() |
| runDB([]string{"query", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--sql", "SELECT body FROM t ORDER BY id LIMIT 2"}) |
diff --git a/internal/dbdoor/restore.go b/internal/dbdoor/restore.go| index 8eee6edb2..9f5690d5e 100644 |
| --- a/internal/dbdoor/restore.go |
| +++ b/internal/dbdoor/restore.go |
| @@ -37,8 +37,8 @@ var ( |
| ) |
| // BeforeHistoryError is ErrBeforeHistory with the oldest point the replica |
| -// keeps: the earliest time a file it could start a restore from was |
| -// written. Oldest is zero only when no listing named one. |
| +// keeps: the earliest time an as-of restore can ask for, the first whole |
| +// second after the oldest file a restore could start from was listed. |
| type BeforeHistoryError struct { |
| Oldest time.Time |
| } |
| @@ -93,7 +93,8 @@ func RestoreTo(ctx context.Context, c *Client, parent string) (*Restored, error) |
| // The plan is litestream's own (Timestamp set, TXID not). It keeps a file |
| // only if its listed time is before at, and the door lists S3's |
| // LastModified, the upload time, which is never earlier than the writes a |
| -// file holds. So the copy can be older than at, never newer. Restored.At |
| +// file holds. at is rounded down to its second first (restoreOnce says |
| +// why). So the copy can be older than at, never newer. Restored.At |
| // says what it actually holds. |
| func RestoreAsOf(ctx context.Context, c *Client, parent string, at time.Time) (*Restored, error) { |
| dir, err := os.MkdirTemp(parent, "carlos-db-") |
| @@ -140,8 +141,10 @@ func RestoreAsOf(ctx context.Context, c *Client, parent string, at time.Time) (* |
| return nil, ErrNoTimes |
| case errors.Is(err, litestream.ErrNoSnapshots) || errors.Is(err, litestream.ErrTxNotAvailable): |
| if oldest := rec.oldestStart(); !at.IsZero() && !oldest.IsZero() { |
| - if !oldest.Before(at) { |
| - return nil, &BeforeHistoryError{Oldest: oldest} |
| + if !oldest.Before(at.Truncate(time.Second)) { |
| + // The first whole second after the oldest file: the |
| + // earliest --at that, rounded down, still includes it. |
| + return nil, &BeforeHistoryError{Oldest: oldest.Truncate(time.Second).Add(time.Second)} |
| } |
| return nil, err |
| } |
| @@ -174,7 +177,11 @@ func RestoreAsOf(ctx context.Context, c *Client, parent string, at time.Time) (* |
| func restoreOnce(ctx context.Context, client litestream.ReplicaClient, out string, at time.Time) error { |
| opt := litestream.NewRestoreOptions() |
| opt.OutputPath = out |
| - opt.Timestamp = at |
| + // S3 lists LastModified to the whole second, rounded down: a file |
| + // uploaded at 14:00:00.7 lists as 14:00:00, before a T of 14:00:00.5, |
| + // though it holds writes after T. Rounding T down to its second keeps |
| + // it out: a listed time before a whole second means an upload before it. |
| + opt.Timestamp = at.Truncate(time.Second) |
| // Follow stays false (spec ยง12 condition ii); IntegrityCheck is left at |
| // litestream's default and CheckDB runs after (condition iii). |
| return litestream.NewReplicaWithClient(nil, client).Restore(ctx, opt) |
diff --git a/internal/dbdoor/restore_asof_test.go b/internal/dbdoor/restore_asof_test.go| index 22bbd2f37..77a4ecebd 100644 |
| --- a/internal/dbdoor/restore_asof_test.go |
| +++ b/internal/dbdoor/restore_asof_test.go |
| @@ -32,12 +32,13 @@ func addBatch(t *testing.T, src, dir string, batch int) { |
| dbdoortest.Sync(t, src, dir) |
| } |
| -// pause makes sure the next file's time is strictly after the last one's, |
| -// whatever the filesystem's timestamp resolution. |
| +// 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(20 * time.Millisecond) |
| + time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond))) |
| at := time.Now() |
| - time.Sleep(20 * time.Millisecond) |
| + time.Sleep(time.Until(at.Truncate(time.Second).Add(time.Second + 10*time.Millisecond))) |
| return at |
| } |
| @@ -99,6 +100,13 @@ func TestRestoreAsOfBeforeTheHistory(t *testing.T) { |
| 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. |
| @@ -117,8 +125,25 @@ func TestRestoreAsOfEmptyReplica(t *testing.T) { |
| func TestRestoreAsOfRefusesAListingWithoutTimes(t *testing.T) { |
| dir := t.TempDir() |
| dbdoortest.Replicate(t, dir, 2) |
| - door := newTestDoor(t, dir) |
| - untimed := func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| + 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 |
| @@ -128,23 +153,36 @@ func TestRestoreAsOfRefusesAListingWithoutTimes(t *testing.T) { |
| return nil, err |
| } |
| for i := range list.Files { |
| - list.Files[i].CreatedAt = time.Time{} |
| + edit(&list.Files[i]) |
| } |
| rec := httptest.NewRecorder() |
| json.NewEncoder(rec).Encode(list) |
| return rec.Result(), nil |
| } |
| - 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()) |
| +// 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.Fatalf("RestoreTo over an untimed listing = %v, want the newest copy", err) |
| + 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) |
| } |
| - os.RemoveAll(got.Dir) |
| } |