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 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "github.com/benbjohnson/litestream"
20 lsfile "github.com/benbjohnson/litestream/file"
21 "github.com/superfly/ltx"
22
23 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
24)
25
26var _ litestream.ReplicaClient = (*Client)(nil)
27
28// noNetwork fails the test if the client ever reaches for the wire.
29func noNetwork(t *testing.T) Doer {
30 return func(context.Context, string, http.Header) (*http.Response, error) {
31 t.Error("the client made a request")
32 return nil, errors.New("no network in this test")
33 }
34}
35
36// The claim the whole design rests on: this client cannot corrupt a
37// replica, because it has no code that writes to one. And it refuses
38// without touching the network.
39func TestClientMutatingMethodsAlwaysError(t *testing.T) {
40 c := NewClient(noNetwork(t))
41 ctx := context.Background()
42 if _, err := c.WriteLTXFile(ctx, 0, 1, 2, strings.NewReader("x")); err == nil {
43 t.Error("WriteLTXFile returned nil; this client must never write")
44 }
45 if err := c.DeleteLTXFiles(ctx, []*ltx.FileInfo{{Level: 0, MinTXID: 1, MaxTXID: 1}}); err == nil {
46 t.Error("DeleteLTXFiles returned nil; this client must never delete")
47 }
48 if err := c.DeleteAll(ctx); err == nil {
49 t.Error("DeleteAll returned nil; this client must never delete")
50 }
51}
52
53// Restore type-asserts for ReplicaClientV3 and would take the v0.3 path if
54// this satisfied it. It must not.
55func TestClientIsNotAV3ReplicaClient(t *testing.T) {
56 var c any = NewClient(noNetwork(t))
57 if _, ok := c.(litestream.ReplicaClientV3); ok {
58 t.Fatal("client satisfies ReplicaClientV3; Restore would take the v0.3 path")
59 }
60}
61
62// testDoor serves a file replica over the door's wire shape: the same
63// coordinates, the same Range forms, the same 404 for a missing file. It
64// stands in for the console here; internal/console tests the real door
65// with this package's client.
66type testDoor struct {
67 t *testing.T
68 rc *lsfile.ReplicaClient
69 mu sync.Mutex
70 lists map[int]int // level -> listing count
71 gone map[string]bool
72 opened int
73}
74
75func newTestDoor(t *testing.T, dir string) *testDoor {
76 return &testDoor{t: t, rc: lsfile.NewReplicaClient(dir), lists: map[int]int{}, gone: map[string]bool{}}
77}
78
79func (d *testDoor) do(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
80 rec := httptest.NewRecorder()
81 d.serve(ctx, rec, tail, h)
82 return rec.Result(), nil
83}
84
85func (d *testDoor) serve(ctx context.Context, w http.ResponseWriter, tail string, h http.Header) {
86 parts := strings.Split(strings.TrimPrefix(tail, "/ltx/"), "/")
87 level, err := strconv.Atoi(parts[0])
88 if err != nil {
89 http.NotFound(w, nil)
90 return
91 }
92 if len(parts) == 1 {
93 d.mu.Lock()
94 d.lists[level]++
95 d.mu.Unlock()
96 itr, _ := d.rc.LTXFiles(ctx, level, 0, false)
97 out := ListResponse{Files: []LTXFile{}}
98 for itr.Next() {
99 f := itr.Item()
100 out.Files = append(out.Files, LTXFile{Level: level, MinTXID: uint64(f.MinTXID), MaxTXID: uint64(f.MaxTXID), Size: f.Size, CreatedAt: f.CreatedAt})
101 }
102 itr.Close()
103 json.NewEncoder(w).Encode(out)
104 return
105 }
106 min, max, err := ParseSpan(parts[1])
107 if err != nil {
108 http.NotFound(w, nil)
109 return
110 }
111 d.mu.Lock()
112 gone := d.gone[parts[1]]
113 if gone {
114 delete(d.gone, parts[1]) // gone once: a fresh plan finds it again
115 }
116 d.opened++
117 d.mu.Unlock()
118 if gone {
119 http.NotFound(w, nil)
120 return
121 }
122 off, size, err := ParseRange(h.Get("Range"))
123 if err != nil {
124 w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
125 return
126 }
127 rc, err := d.rc.OpenLTXFile(ctx, level, ltx.TXID(min), ltx.TXID(max), off, size)
128 if errors.Is(err, os.ErrNotExist) {
129 http.NotFound(w, nil)
130 return
131 }
132 if err != nil {
133 w.WriteHeader(http.StatusInternalServerError)
134 return
135 }
136 defer rc.Close()
137 w.WriteHeader(http.StatusPartialContent)
138 io.Copy(w, rc)
139}
140
141// counting wraps the client and counts every method litestream calls on it.
142type counting struct {
143 *Client
144 mu sync.Mutex
145 counts map[string]int
146}
147
148func (c *counting) inc(m string) {
149 c.mu.Lock()
150 defer c.mu.Unlock()
151 c.counts[m]++
152}
153
154func (c *counting) WriteLTXFile(ctx context.Context, level int, min, max ltx.TXID, r io.Reader) (*ltx.FileInfo, error) {
155 c.inc("WriteLTXFile")
156 return c.Client.WriteLTXFile(ctx, level, min, max, r)
157}
158
159func (c *counting) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) error {
160 c.inc("DeleteLTXFiles")
161 return c.Client.DeleteLTXFiles(ctx, a)
162}
163
164func (c *counting) DeleteAll(ctx context.Context) error {
165 c.inc("DeleteAll")
166 return c.Client.DeleteAll(ctx)
167}
168
169func (c *counting) LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
170 c.inc("LTXFiles")
171 return c.Client.LTXFiles(ctx, level, seek, useMetadata)
172}
173
174func (c *counting) OpenLTXFile(ctx context.Context, level int, min, max ltx.TXID, offset, size int64) (io.ReadCloser, error) {
175 c.inc("OpenLTXFile")
176 return c.Client.OpenLTXFile(ctx, level, min, max, offset, size)
177}
178
179// The methods erroring is not enough: a restore that CALLED one would be
180// relying on an error path. This asserts a full restore never reaches them,
181// which is what catches a litestream upgrade changing the restore path.
182func TestRestoreNeverCallsAMutatingMethod(t *testing.T) {
183 dir := t.TempDir()
184 dbdoortest.Replicate(t, dir, 3)
185 door := newTestDoor(t, dir)
186 c := &counting{Client: NewClient(door.do), counts: map[string]int{}}
187 out := filepath.Join(t.TempDir(), "out.db")
188 opt := litestream.NewRestoreOptions()
189 opt.OutputPath = out
190 if err := litestream.NewReplicaWithClient(nil, c).Restore(context.Background(), opt); err != nil {
191 t.Fatal(err)
192 }
193 if n := c.counts["WriteLTXFile"] + c.counts["DeleteLTXFiles"] + c.counts["DeleteAll"]; n != 0 {
194 t.Fatalf("restore called a mutating method: %v", c.counts)
195 }
196 if c.counts["LTXFiles"] == 0 || c.counts["OpenLTXFile"] == 0 {
197 t.Fatalf("restore did not go through the client at all: %v", c.counts)
198 }
199 if rows := countRows(t, out); rows != 3*dbdoortest.Rows {
200 t.Fatalf("restored %d rows, want %d", rows, 3*dbdoortest.Rows)
201 }
202}
203
204func countRows(t *testing.T, path string) int {
205 t.Helper()
206 db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
207 if err != nil {
208 t.Fatal(err)
209 }
210 defer db.Close()
211 var n int
212 if err := db.QueryRow(`SELECT count(*) FROM t`).Scan(&n); err != nil {
213 t.Fatal(err)
214 }
215 return n
216}
217
218// seek is not on the wire (wire specific 3): the client filters locally,
219// with litestream's own rule (MinTXID >= seek).
220func TestClientFiltersSeekLocally(t *testing.T) {
221 dir := t.TempDir()
222 dbdoortest.Replicate(t, dir, 3)
223 c := NewClient(newTestDoor(t, dir).do)
224 all := collect(t, c, 0, 0)
225 if len(all) < 2 {
226 t.Fatalf("want several files, got %d", len(all))
227 }
228 seek := all[1].MinTXID
229 got := collect(t, c, 0, seek)
230 if len(got) != len(all)-1 || got[0].MinTXID != seek {
231 t.Fatalf("seek %d = %d files starting %d, want %d starting %d", seek, len(got), got[0].MinTXID, len(all)-1, seek)
232 }
233}
234
235func collect(t *testing.T, c *Client, level int, seek ltx.TXID) []*ltx.FileInfo {
236 t.Helper()
237 itr, err := c.LTXFiles(context.Background(), level, seek, false)
238 if err != nil {
239 t.Fatal(err)
240 }
241 defer itr.Close()
242 var out []*ltx.FileInfo
243 for itr.Next() {
244 out = append(out, itr.Item())
245 }
246 return out
247}
248
249// A 404 on a file is os.ErrNotExist, which is what the whole-Restore retry
250// keys on; any other refusal carries the console's own sentence.
251func TestClientMapsTheDoorsAnswers(t *testing.T) {
252 answer := func(status int, body string) Doer {
253 return func(context.Context, string, http.Header) (*http.Response, error) {
254 rec := httptest.NewRecorder()
255 rec.WriteHeader(status)
256 io.WriteString(rec, body)
257 return rec.Result(), nil
258 }
259 }
260 ctx := context.Background()
261 if _, err := NewClient(answer(404, "404 page not found\n")).OpenLTXFile(ctx, 0, 1, 1, 0, 0); !errors.Is(err, os.ErrNotExist) {
262 t.Errorf("open on 404 = %v, want os.ErrNotExist", err)
263 }
264 _, err := NewClient(answer(409, `{"error":"the data is elsewhere"}`)).LTXFiles(ctx, 0, 0, false)
265 var de *DoorError
266 if !errors.As(err, &de) || de.Status != 409 || de.Message != "the data is elsewhere" {
267 t.Errorf("list on 409 = %v, want a DoorError carrying the sentence", err)
268 }
269 if _, err := NewClient(answer(409, `{"error":"x"}`)).OpenLTXFile(ctx, 0, 1, 1, 0, 0); errors.Is(err, os.ErrNotExist) || !errors.As(err, &de) {
270 t.Errorf("open on 409 = %v, want a DoorError and not ErrNotExist", err)
271 }
272}
273
274// The client sends the S3 client's exact Range header and the coordinate
275// tail, nothing else.
276func TestClientSendsCoordinatesAndRange(t *testing.T) {
277 var gotTail, gotRange string
278 c := NewClient(func(_ context.Context, tail string, h http.Header) (*http.Response, error) {
279 gotTail, gotRange = tail, h.Get("Range")
280 rec := httptest.NewRecorder()
281 rec.WriteHeader(http.StatusPartialContent)
282 return rec.Result(), nil
283 })
284 rc, err := c.OpenLTXFile(context.Background(), 3, 16, 32, 512, 100)
285 if err != nil {
286 t.Fatal(err)
287 }
288 rc.Close()
289 if gotTail != GetTail(3, 16, 32) || gotRange != RangeHeader(512, 100) {
290 t.Fatalf("tail %q range %q, want %q %q", gotTail, gotRange, GetTail(3, 16, 32), RangeHeader(512, 100))
291 }
292}
293
294// Retry-After is capped at a minute however large the door's number, and a
295// number too big to multiply into a Duration must not wrap round to a
296// short or negative wait.
297func TestClientCapsRetryAfter(t *testing.T) {
298 for _, v := range []string{"61", "3600", "18446744074", "9223372036", "9223372037"} {
299 do := func(context.Context, string, http.Header) (*http.Response, error) {
300 rec := httptest.NewRecorder()
301 rec.Header().Set("Retry-After", v)
302 rec.WriteHeader(http.StatusTooManyRequests)
303 io.WriteString(rec, `{"error":"busy"}`)
304 return rec.Result(), nil
305 }
306 _, err := NewClient(do).OpenLTXFile(context.Background(), 0, 1, 1, 0, 0)
307 var de *DoorError
308 if !errors.As(err, &de) || de.RetryAfter != time.Minute {
309 t.Errorf("Retry-After: %s gave %v, want the one-minute cap", v, de.RetryAfter)
310 }
311 }
312}
313