carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package dbdoor
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
16)
17
18// addBatch inserts one more batch into src and replicates it into dir.
19func addBatch(t *testing.T, src, dir string, batch int) {
20 t.Helper()
21 db, err := sql.Open("sqlite", src)
22 if err != nil {
23 t.Fatal(err)
24 }
25 for i := 0; i < dbdoortest.Rows; i++ {
26 if _, err := db.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("row-%d-%d", batch, i)); err != nil {
27 db.Close()
28 t.Fatal(err)
29 }
30 }
31 db.Close()
32 dbdoortest.Sync(t, src, dir)
33}
34
35// pause returns a time in a whole second of its own: after every file
36// written so far, and before the next one. RestoreAsOf rounds its time down
37// to the second, as S3's listing does.
38func pause() time.Time {
39 time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
40 at := time.Now()
41 time.Sleep(time.Until(at.Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
42 return at
43}
44
45// A copy as of T holds what was replicated before T and nothing after it.
46func TestRestoreAsOfStopsAtTheTime(t *testing.T) {
47 dir := t.TempDir()
48 src := dbdoortest.Replicate(t, dir, 2)
49 at := pause()
50 addBatch(t, src, dir, 2)
51 c := NewClient(newTestDoor(t, dir).do)
52 latest := collect(t, c, 0, 0)
53
54 got, err := RestoreAsOf(context.Background(), c, t.TempDir(), at)
55 if err != nil {
56 t.Fatal(err)
57 }
58 defer os.RemoveAll(got.Dir)
59 if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows {
60 t.Fatalf("copy as of %v holds %d rows, want %d (the third batch came after)", at, rows, 2*dbdoortest.Rows)
61 }
62 if got.TXID >= uint64(latest[len(latest)-1].MaxTXID) {
63 t.Fatalf("TXID = %d, want one before the newest (%d)", got.TXID, latest[len(latest)-1].MaxTXID)
64 }
65 if !got.At.Before(at) {
66 t.Fatalf("At = %v, want a time before the requested %v", got.At, at)
67 }
68}
69
70// A time after the newest write is simply the newest copy.
71func TestRestoreAsOfAfterTheNewestWrite(t *testing.T) {
72 dir := t.TempDir()
73 dbdoortest.Replicate(t, dir, 2)
74 got, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), pause())
75 if err != nil {
76 t.Fatal(err)
77 }
78 defer os.RemoveAll(got.Dir)
79 if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows {
80 t.Fatalf("restored %d rows, want %d", rows, 2*dbdoortest.Rows)
81 }
82}
83
84// A time before anything the replica keeps is its own answer, naming the
85// oldest point it does keep, and not "nothing replicated".
86func TestRestoreAsOfBeforeTheHistory(t *testing.T) {
87 at := time.Now().Add(-time.Hour)
88 dir := t.TempDir()
89 dbdoortest.Replicate(t, dir, 2)
90 parent := t.TempDir()
91 _, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), parent, at)
92 if !errors.Is(err, ErrBeforeHistory) {
93 t.Fatalf("RestoreAsOf before the history = %v, want ErrBeforeHistory", err)
94 }
95 if errors.Is(err, ErrNothingReplicated) {
96 t.Fatalf("RestoreAsOf before the history = %v, must not read as nothing replicated", err)
97 }
98 var bh *BeforeHistoryError
99 if !errors.As(err, &bh) || bh.Oldest.IsZero() || !bh.Oldest.After(at) {
100 t.Fatalf("RestoreAsOf before the history = %#v, want the oldest kept point, after %v", err, at)
101 }
102 assertEmptyDir(t, parent)
103
104 // The point it names is one a person can ask for.
105 got, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), bh.Oldest)
106 if err != nil {
107 t.Fatalf("RestoreAsOf at the named oldest point %v = %v, want a copy", bh.Oldest, err)
108 }
109 os.RemoveAll(got.Dir)
110}
111
112// An empty replica is still "nothing replicated", with or without a time.
113func TestRestoreAsOfEmptyReplica(t *testing.T) {
114 parent := t.TempDir()
115 _, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, t.TempDir()).do), parent, time.Now())
116 if !errors.Is(err, ErrNothingReplicated) {
117 t.Fatalf("RestoreAsOf of an empty replica = %v, want ErrNothingReplicated", err)
118 }
119 assertEmptyDir(t, parent)
120}
121
122// A listing with no times (the claim-mode door sends none) must refuse:
123// litestream reads a zero time as "before T" and would hand back the
124// newest copy labelled as the requested one.
125func TestRestoreAsOfRefusesAListingWithoutTimes(t *testing.T) {
126 dir := t.TempDir()
127 dbdoortest.Replicate(t, dir, 2)
128 untimed := relist(newTestDoor(t, dir), func(f *LTXFile) { f.CreatedAt = time.Time{} })
129 parent := t.TempDir()
130 _, err := RestoreAsOf(context.Background(), NewClient(untimed), parent, time.Now())
131 if !errors.Is(err, ErrNoTimes) {
132 t.Fatalf("RestoreAsOf over an untimed listing = %v, want ErrNoTimes", err)
133 }
134 assertEmptyDir(t, parent)
135
136 // Without a time, the same listing restores as before.
137 got, err := RestoreTo(context.Background(), NewClient(untimed), t.TempDir())
138 if err != nil {
139 t.Fatalf("RestoreTo over an untimed listing = %v, want the newest copy", err)
140 }
141 os.RemoveAll(got.Dir)
142}
143
144// relist serves door's listings with each file passed through edit.
145func relist(door *testDoor, edit func(*LTXFile)) Doer {
146 return func(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
147 res, err := door.do(ctx, tail, h)
148 if err != nil || res.StatusCode != http.StatusOK || h.Get("Range") != "" {
149 return res, err
150 }
151 var list ListResponse
152 if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
153 return nil, err
154 }
155 for i := range list.Files {
156 edit(&list.Files[i])
157 }
158 rec := httptest.NewRecorder()
159 json.NewEncoder(rec).Encode(list)
160 return rec.Result(), nil
161 }
162}
163
164// S3 lists LastModified to the whole second, rounded down, so a file
165// uploaded just after T in the same second lists as before T. The copy must
166// still leave it out.
167func TestRestoreAsOfWithSecondResolutionTimes(t *testing.T) {
168 dir := t.TempDir()
169 src := dbdoortest.Replicate(t, dir, 2)
170 // Start just after a whole second, so batch three lands in the same
171 // second as T but after it.
172 time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 100*time.Millisecond)))
173 at := time.Now()
174 time.Sleep(50 * time.Millisecond)
175 addBatch(t, src, dir, 2)
176 if done := time.Now(); done.Truncate(time.Second) != at.Truncate(time.Second) {
177 t.Skipf("batch three took until %v, past T's second; the case needs it inside", done)
178 }
179 c := NewClient(relist(newTestDoor(t, dir), func(f *LTXFile) { f.CreatedAt = f.CreatedAt.Truncate(time.Second) }))
180 got, err := RestoreAsOf(context.Background(), c, t.TempDir(), at)
181 if err != nil {
182 t.Fatal(err)
183 }
184 defer os.RemoveAll(got.Dir)
185 if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows {
186 t.Fatalf("copy as of %v holds %d rows, want %d: a write after T is in it", at, rows, 2*dbdoortest.Rows)
187 }
188}
189
190// Go's zero time means "the newest copy" to RestoreAsOf and to litestream.
191// A requested time at or near it (year 1, or a fraction of a second into
192// it that rounds down to zero) must still be a time before the history,
193// never the newest copy. Found in Astra's review. The zero instant itself,
194// in any zone, is "the newest copy" by contract; parseDBAt keeps a typed
195// one from reaching here.
196func TestRestoreAsOfNeverTreatsAnEarlyTimeAsTheNewest(t *testing.T) {
197 dir := t.TempDir()
198 dbdoortest.Replicate(t, dir, 2)
199 for _, at := range []time.Time{
200 time.Time{}.Add(500 * time.Millisecond),
201 time.Time{}.Add(time.Hour),
202 } {
203 _, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), at)
204 if !errors.Is(err, ErrBeforeHistory) {
205 t.Errorf("RestoreAsOf(%v) = %v, want ErrBeforeHistory", at, err)
206 }
207 }
208}
209