carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "crypto/ecdh"
8 "crypto/ed25519"
9 "crypto/rand"
10 "crypto/sha256"
11 "encoding/base64"
12 "encoding/hex"
13 "encoding/json"
14 "fmt"
15 "io"
16 "net"
17 "net/http"
18 "net/http/httptest"
19 "path/filepath"
20 "slices"
21 "strings"
22 "sync"
23 "testing"
24 "time"
25
26 lsfile "github.com/benbjohnson/litestream/file"
27
28 "github.com/carlosframework/platform/internal/dbdoor"
29 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
30 "github.com/carlosframework/platform/internal/dbquery"
31 "github.com/carlosframework/platform/internal/dbreads"
32 "github.com/carlosframework/platform/internal/dbreads/dbreadstest"
33 "github.com/carlosframework/platform/internal/provision"
34 "github.com/carlosframework/platform/internal/release"
35)
36
37// The console forwards only with the switch on, a root key whose public
38// half is the deployment's, and no host claims (spec §5.5, §9.1, §11.1).
39func TestDBForwardSignerNeedsTheSwitchAndTheRootKey(t *testing.T) {
40 t.Parallel()
41 pub, priv, _ := ed25519.GenerateKey(rand.Reader)
42 _, other, _ := ed25519.GenerateKey(rand.Reader)
43 for name, c := range map[string]struct {
44 cfg Config
45 want bool
46 }{
47 "on, root key": {Config{DBForward: true, Key: priv, ReleasePub: pub}, true},
48 "off": {Config{Key: priv, ReleasePub: pub}, false},
49 "no key": {Config{DBForward: true, ReleasePub: pub}, false},
50 "not the root's key": {Config{DBForward: true, Key: other, ReleasePub: pub}, false},
51 "host claims": {Config{DBForward: true, Key: priv, ReleasePub: pub, HostClaim: true}, false},
52 } {
53 s := &Server{cfg: c.cfg}
54 if _, ok := s.dbForwardSigner(); ok != c.want {
55 t.Errorf("%s: %v", name, ok)
56 }
57 }
58}
59
60func TestNewHTTPServerKeepsTheConsolesTimeouts(t *testing.T) {
61 t.Parallel()
62 srv := NewHTTPServer(nil)
63 if srv.ReadHeaderTimeout != 10*time.Second || srv.ReadTimeout != 5*time.Minute || srv.WriteTimeout != 0 {
64 t.Fatalf("%+v", srv)
65 }
66}
67
68// putFixtureObject creates key, or replaces it under its current etag: a
69// fixture may republish a box's files, as a box does every pass. Protocol
70// writes stay create-only; this helper is for fixtures only (plan review
71// finding 6: DirStore.Put refuses a create over an existing key).
72func putFixtureObject(t *testing.T, s release.Store, key string, body []byte) {
73 t.Helper()
74 etag := ""
75 if _, cur, err := s.Get(context.Background(), key); err == nil {
76 etag = cur
77 }
78 if _, err := s.Put(context.Background(), key, body, etag); err != nil {
79 t.Fatal(err)
80 }
81}
82
83// putEdgesFile writes control/edges/<box>.json as a box publishes it.
84func putEdgesFile(t *testing.T, s release.Store, box, placement string, caps []string, draining bool) {
85 t.Helper()
86 body, _ := json.Marshal(map[string]any{"v": 1, "box": box, "eip": "203.0.113.10", "placement": placement,
87 "capabilities": caps, "draining": draining, "hosts": map[string]any{}})
88 putFixtureObject(t, s, "control/edges/"+box+".json", body)
89}
90
91// putBoxStatus writes status/boxes/<box>.json generated at at.
92func putBoxStatus(t *testing.T, s release.Store, box string, at time.Time) {
93 t.Helper()
94 body, _ := json.Marshal(map[string]any{"generated_at": at.UTC(), "label": box})
95 putFixtureObject(t, s, "status/boxes/"+box+".json", body)
96}
97
98// T14 (codex 10): only complete evidence, both flags, refuses up front.
99// Incomplete evidence of any kind admits the read and lets the pickup
100// window decide. Mutation: check Complete and not BoxesComplete (red: the
101// missing, unmatched and unreadable status cases refuse).
102func TestDBForwardEvidenceRefusesOnlyOnCompleteEvidence(t *testing.T) {
103 t.Parallel()
104 now := time.Now()
105 capable := []string{dbreads.Capability}
106 for name, c := range map[string]struct {
107 setup func(t *testing.T, s release.Store)
108 want string
109 }{
110 "a capable live box": {func(t *testing.T, s release.Store) {
111 putEdgesFile(t, s, "uk-1", "uk", capable, false)
112 putBoxStatus(t, s, "uk-1", now)
113 }, ""},
114 "no capable box": {func(t *testing.T, s release.Store) {
115 putEdgesFile(t, s, "uk-1", "uk", nil, false)
116 putBoxStatus(t, s, "uk-1", now)
117 }, dbForwardNone},
118 "a capable box that is draining": {func(t *testing.T, s release.Store) {
119 putEdgesFile(t, s, "uk-1", "uk", capable, true)
120 putBoxStatus(t, s, "uk-1", now)
121 }, dbForwardNone},
122 "capable but stale": {func(t *testing.T, s release.Store) {
123 putEdgesFile(t, s, "uk-1", "uk", capable, false)
124 putBoxStatus(t, s, "uk-1", now.Add(-time.Hour))
125 }, dbForwardDead},
126 "capable in another region only": {func(t *testing.T, s release.Store) {
127 putEdgesFile(t, s, "ie-1", "ie", capable, false)
128 putBoxStatus(t, s, "ie-1", now)
129 }, dbForwardNone},
130 "a status snapshot missing": {func(t *testing.T, s release.Store) {
131 putEdgesFile(t, s, "uk-1", "uk", nil, false)
132 }, ""},
133 "an edges file unreadable": {func(t *testing.T, s release.Store) {
134 s.Put(context.Background(), "control/edges/uk-1.json", []byte("{not json"), "")
135 putBoxStatus(t, s, "uk-1", now)
136 }, ""},
137 "a fresh snapshot with no edges file": {func(t *testing.T, s release.Store) {
138 putEdgesFile(t, s, "uk-1", "uk", nil, false)
139 putBoxStatus(t, s, "uk-1", now)
140 putBoxStatus(t, s, "uk-2", now)
141 }, ""},
142 "status snapshots unreadable": {func(t *testing.T, s release.Store) {
143 putEdgesFile(t, s, "uk-1", "uk", nil, false)
144 s.Put(context.Background(), "status/boxes/uk-1.json", []byte("{not json"), "")
145 }, ""},
146 } {
147 t.Run(name, func(t *testing.T) {
148 t.Parallel()
149 s := release.NewDirStore(t.TempDir())
150 c.setup(t, s)
151 snap, err := readLiveEdgesSnapshot(context.Background(), s, now)
152 if got := dbForwardEvidence(snap, err, "uk"); got != c.want {
153 t.Fatalf("evidence %q, want %q (complete %v, boxes %v)", got, c.want, snap.Complete, snap.BoxesComplete)
154 }
155 })
156 }
157}
158
159const dbfHost = "shop.example.com"
160
161// dbfFixture is a console in ie with forwarding on or off, a member, and
162// shop.example.com's record in uk with a capable, live uk box published.
163type dbfFixture struct {
164 *consoleFixture
165 member string
166}
167
168func newDBFFixture(t *testing.T, forward bool) *dbfFixture {
169 t.Helper()
170 fx := newConsoleFixture(t)
171 fx.srv.cfg.DBForward = forward
172 fx.srv.cfg.Region = "ie"
173 fx.srv.cfg.HibernateBucket = "data-ie"
174 if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil {
175 t.Fatal(err)
176 }
177 if err := fx.st.AddMember(OpsAccountID, "member@example.com", RoleMember, time.Now()); err != nil {
178 t.Fatal(err)
179 }
180 member := cliToken(t, fx, "member@example.com", "code-dbf")
181 putShopRecord(t, fx, provision.Record{Host: dbfHost, Region: "uk", CreatedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)})
182 putEdgesFile(t, fx.rel, "uk-1", "uk", []string{dbreads.Capability}, false)
183 putBoxStatus(t, fx.rel, "uk-1", time.Now())
184 return &dbfFixture{consoleFixture: fx, member: member}
185}
186
187func (fx *dbfFixture) listURL() string {
188 return fx.ts.URL + "/api/cli/apps/" + release.OpsAccount + "/shop/db/" + dbfHost + "/ltx/9"
189}
190
191// T8, the console's half: forwarding off is main's answer byte for byte.
192// The want is main's dbResidencyRefusal for this fixture, confirmed against
193// main's code before this branch changed dbGate.
194func TestListDoorWithForwardingOffIsMains(t *testing.T) {
195 fx := newDBFFixture(t, false)
196 status, _, body := rawReq(t, http.MethodGet, fx.listURL(), fx.member, "")
197 want := `{"error":"shop.example.com keeps its data in region uk. This console can only read data kept in region ie."}`
198 if status != http.StatusConflict || strings.TrimSpace(body) != want {
199 t.Fatalf("%d %s", status, body)
200 }
201}
202
203func TestListDoorMarksACapableRegion(t *testing.T) {
204 fx := newDBFFixture(t, true)
205 status, _, body := rawReq(t, http.MethodGet, fx.listURL(), fx.member, "")
206 var got dbdoor.ForwardBody
207 json.Unmarshal([]byte(body), &got)
208 if status != http.StatusConflict || got.Forward != 1 || got.Region != "uk" ||
209 !slices.Equal(got.ForwardOps, []string{dbreads.OpQuery, dbreads.OpPull}) ||
210 got.Error != fmt.Sprintf(dbfUpdateCarlos, dbfHost, "uk") {
211 t.Fatalf("%d %s", status, body)
212 }
213}
214
215func TestListDoorRefusesOnCompleteEvidence(t *testing.T) {
216 fx := newDBFFixture(t, true)
217 putEdgesFile(t, fx.rel, "uk-1", "uk", nil, false)
218 status, _, body := rawReq(t, http.MethodGet, fx.listURL(), fx.member, "")
219 if status != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(dbfNoCapable, dbfHost, "uk")) || strings.Contains(body, `"forward"`) {
220 t.Fatalf("no capable box: %d %s", status, body)
221 }
222 putEdgesFile(t, fx.rel, "uk-2", "uk", []string{dbreads.Capability}, false)
223 putBoxStatus(t, fx.rel, "uk-2", time.Now().Add(-time.Hour))
224 status, _, body = rawReq(t, http.MethodGet, fx.listURL(), fx.member, "")
225 if status != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(dbfNoneLive, dbfHost, "uk")) {
226 t.Fatalf("none live: %d %s", status, body)
227 }
228}
229
230// Review Focus 5: a home-region record seen from a regional console keeps
231// main's sentence; there is no region to forward to by name.
232func TestListDoorKeepsMainsSentenceForTheHomeRegion(t *testing.T) {
233 fx := newDBFFixture(t, true)
234 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "home.example.com", CreatedAt: time.Now().UTC()})
235 u := fx.ts.URL + "/api/cli/apps/" + release.OpsAccount + "/shop/db/home.example.com/ltx/9"
236 status, _, body := rawReq(t, http.MethodGet, u, fx.member, "")
237 if status != http.StatusConflict || strings.Contains(body, `"forward"`) || !strings.Contains(body, "This console can only read data kept in") {
238 t.Fatalf("%d %s", status, body)
239 }
240}
241
242func (fx *dbfFixture) readURL(host string) string {
243 return fx.ts.URL + "/api/cli/apps/" + release.OpsAccount + "/shop/db/" + host + "/reads"
244}
245
246func queryBody(t *testing.T, sqlText string) (dbreads.ReadBody, *ecdh.PrivateKey) {
247 t.Helper()
248 key, err := dbreads.NewReplyKey()
249 if err != nil {
250 t.Fatal(err)
251 }
252 return dbreads.ReadBody{Op: dbreads.OpQuery, SQL: sqlText, ReplyKey: dbreads.EncodeReplyKey(key.PublicKey())}, key
253}
254
255// openRead posts a read and returns the open response; the caller reads its
256// lines or closes it.
257func (fx *dbfFixture) openRead(t *testing.T, host, bearer string, body dbreads.ReadBody) *http.Response {
258 t.Helper()
259 raw, _ := json.Marshal(body)
260 req, _ := http.NewRequest(http.MethodPost, fx.readURL(host), bytes.NewReader(raw))
261 req.Header.Set("Content-Type", "application/json")
262 req.Header.Set("Authorization", "Bearer "+bearer)
263 res, err := http.DefaultClient.Do(req)
264 if err != nil {
265 t.Fatal(err)
266 }
267 return res
268}
269
270func readLines(t *testing.T, r io.Reader) []dbreads.Line {
271 t.Helper()
272 sc := bufio.NewScanner(r)
273 sc.Buffer(make([]byte, 64<<10), dbreads.MaxLineBytes)
274 var out []dbreads.Line
275 for sc.Scan() {
276 var l dbreads.Line
277 if err := json.Unmarshal(sc.Bytes(), &l); err != nil {
278 t.Fatalf("not a line: %s", sc.Bytes())
279 }
280 out = append(out, l)
281 }
282 return out
283}
284
285func refusal(t *testing.T, res *http.Response) (int, string) {
286 t.Helper()
287 defer res.Body.Close()
288 b, _ := io.ReadAll(res.Body)
289 return res.StatusCode, string(b)
290}
291
292func TestDBReadRouteRefusesBeforeAdmitting(t *testing.T) {
293 fx := newDBFFixture(t, true)
294 good, _ := queryBody(t, "SELECT 1")
295 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "here.example.com", Region: "ie", CreatedAt: time.Now().UTC()})
296 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "moving.example.com", Region: "uk", CreatedAt: time.Now().UTC(),
297 Move: &provision.MoveState{ID: "mv_x", To: "ie", State: "requested"}})
298 two := good
299 two.SQL = "SELECT 1; SELECT 2"
300 shell := good
301 shell.Op = "shell"
302 pullSQL := good
303 pullSQL.Op = dbreads.OpPull
304 badKey := good
305 badKey.ReplyKey = "AAAA"
306 future := good
307 future.At = time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
308 for name, c := range map[string]struct {
309 host string
310 body dbreads.ReadBody
311 status int
312 want string
313 }{
314 "an in-region instance": {"here.example.com", good, http.StatusNotFound, ""},
315 "no such instance": {"nope.example.com", good, http.StatusNotFound, ""},
316 "moving": {"moving.example.com", good, http.StatusConflict, fmt.Sprintf(dbfMoving, "moving.example.com")},
317 "two statements": {dbfHost, two, http.StatusBadRequest, dbquery.ErrOneStatement.Error()},
318 "an op this console does not forward": {dbfHost, shell, http.StatusBadRequest, ""},
319 "a pull carrying SQL": {dbfHost, pullSQL, http.StatusBadRequest, ""},
320 "a reply key that is not a key": {dbfHost, badKey, http.StatusBadRequest, ""},
321 "a time in the future": {dbfHost, future, http.StatusBadRequest, ""},
322 } {
323 status, body := refusal(t, fx.openRead(t, c.host, fx.member, c.body))
324 if status != c.status || !strings.Contains(body, c.want) {
325 t.Errorf("%s: %d %s", name, status, body)
326 }
327 }
328 // SQL that JSON decoding would have rewritten (plan review finding 36):
329 // a byte that is not UTF-8, and a lone surrogate escape.
330 for name, sqlJSON := range map[string]string{
331 "a byte that is not UTF-8": "\"SELECT '\xff'\"",
332 "a lone surrogate": `"SELECT '\ud800'"`,
333 } {
334 raw := `{"op":"query","sql":` + sqlJSON + `,"reply_key":"` + good.ReplyKey + `"}`
335 req, _ := http.NewRequest(http.MethodPost, fx.readURL(dbfHost), strings.NewReader(raw))
336 req.Header.Set("Content-Type", "application/json")
337 req.Header.Set("Authorization", "Bearer "+fx.member)
338 res, err := http.DefaultClient.Do(req)
339 if err != nil {
340 t.Fatal(err)
341 }
342 if status, body := refusal(t, res); status != http.StatusBadRequest {
343 t.Errorf("%s: %d %s", name, status, body)
344 }
345 }
346 if keys, _ := fx.rel.List(context.Background(), dbreads.Prefix); len(keys) != 0 {
347 t.Fatalf("a refused read wrote %v", keys)
348 }
349 fx.srv.cfg.DBForward = false
350 status, body := refusal(t, fx.openRead(t, dbfHost, fx.member, good))
351 if status != http.StatusConflict || !strings.Contains(body, "This console can only read data kept in region ie.") {
352 t.Fatalf("forwarding off: %d %s", status, body)
353 }
354 fx.srv.cfg.DBForward, fx.srv.cfg.HostClaim = true, true
355 status, body = refusal(t, fx.openRead(t, dbfHost, fx.member, good))
356 if status != http.StatusConflict || !strings.Contains(body, "This console can only read data kept in") {
357 t.Fatalf("a host-claim console: %d %s", status, body)
358 }
359}
360
361func TestDBReadRouteRefusesOnCompleteEvidence(t *testing.T) {
362 fx := newDBFFixture(t, true)
363 putEdgesFile(t, fx.rel, "uk-1", "uk", nil, false)
364 good, _ := queryBody(t, "SELECT 1")
365 status, body := refusal(t, fx.openRead(t, dbfHost, fx.member, good))
366 if status != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(dbfNoCapable, dbfHost, "uk")) {
367 t.Fatalf("%d %s", status, body)
368 }
369}
370
371// Admission, then the request, the queue entry, the serial and the audit
372// row, all before the 200.
373func TestDBReadQueuesASignedRequestAndAudits(t *testing.T) {
374 fx := newDBFFixture(t, true)
375 fx.srv.dbf.pickup = 200 * time.Millisecond
376 body, _ := queryBody(t, "SELECT count(*) FROM orders")
377 res := fx.openRead(t, dbfHost, fx.member, body)
378 lines := readLines(t, res.Body)
379 res.Body.Close()
380 if res.StatusCode != http.StatusOK || len(lines) == 0 || lines[0].Admitted == "" {
381 t.Fatalf("%d %+v", res.StatusCode, lines)
382 }
383 id := lines[0].Admitted
384 raw, _, err := fx.rel.Get(context.Background(), dbreads.RequestKey(id))
385 if err != nil {
386 t.Fatal(err)
387 }
388 req, err := dbreads.Verify(fx.pub, raw, id, "uk", time.Now())
389 if err != nil || req.SQL != body.SQL || req.Host != dbfHost || req.By != "member@example.com" || req.Account != release.OpsAccount {
390 t.Fatalf("request %+v: %v", req, err)
391 }
392 issued, _ := req.Issued()
393 if _, _, err := fx.rel.Get(context.Background(), dbreads.QueueKey("uk", issued, id)); err != nil {
394 t.Fatalf("no queue entry: %v", err)
395 }
396 if _, _, err := fx.rel.Get(context.Background(), release.SerialKey); err != nil {
397 t.Fatalf("the serial was not bumped: %v", err)
398 }
399 evs, _ := fx.st.Events(OpsAccountID, "shop", 10)
400 want := dbForwardAuditDetail(dbreads.OpQuery, "uk", body.SQL, time.Time{})
401 found := false
402 for _, e := range evs {
403 found = found || (e.Action == "db-read" && e.Target == dbfHost && e.Detail == want)
404 }
405 if !found || strings.Contains(want, "orders") {
406 t.Fatalf("audit %q in %+v", want, evs)
407 }
408}
409
410// dbfPutHook runs hook after each create of a key with suffix, once the
411// create has landed.
412type dbfPutHook struct {
413 release.Store
414 suffix string
415 hook func()
416}
417
418func (h *dbfPutHook) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
419 etag, err := h.Store.Put(ctx, key, body, ifMatch)
420 if strings.HasSuffix(key, h.suffix) || (h.suffix == "queue" && strings.HasPrefix(key, dbreads.Prefix+"queue/")) {
421 h.hook()
422 }
423 return etag, err
424}
425
426// dbReadAudited is safe from a handler's goroutine: it never calls t.Fatal.
427func dbReadAudited(t *testing.T, st *Store) bool {
428 t.Helper()
429 evs, err := st.Events(OpsAccountID, "shop", 10)
430 if err != nil {
431 t.Error(err)
432 return false
433 }
434 for _, e := range evs {
435 if e.Action == "db-read" && e.Target == dbfHost {
436 return true
437 }
438 }
439 return false
440}
441
442// dbfServe drives the handler in process with ctx as the request's own
443// context, so a test can end the request at an exact step.
444func (fx *dbfFixture) dbfServe(t *testing.T, ctx context.Context, sqlText string) *httptest.ResponseRecorder {
445 t.Helper()
446 body, _ := queryBody(t, sqlText)
447 raw, _ := json.Marshal(body)
448 req := httptest.NewRequest(http.MethodPost, fx.readURL(dbfHost), bytes.NewReader(raw)).WithContext(ctx)
449 req.Header.Set("Content-Type", "application/json")
450 req.Header.Set("Authorization", "Bearer "+fx.member)
451 rec := httptest.NewRecorder()
452 fx.srv.ServeHTTP(rec, req)
453 return rec
454}
455
456// dbfBucket is what a read left under db-reads/: request objects, queue
457// entries and cancels, by count.
458func dbfBucket(t *testing.T, s release.Store) (requests, queued, cancels int) {
459 t.Helper()
460 keys, _ := s.List(context.Background(), dbreads.Prefix)
461 for _, k := range keys {
462 switch {
463 case strings.HasPrefix(k, dbreads.Prefix+"queue/"):
464 queued++
465 case strings.HasSuffix(k, "/request.json"):
466 requests++
467 case strings.HasSuffix(k, "/cancel"):
468 cancels++
469 }
470 }
471 return
472}
473
474// Plan review findings 11, 28 and 33 (J1, spec §5.5 step 3 and §9.3 rev 6):
475// the request's context is checked immediately before every write of
476// admission. Ended before the audit row: nothing at all. Before
477// request.json: the row only. Before the queue entry: the row, request.json
478// and the cancel. Before the serial: the row, both objects and the cancel.
479// Never a serial bump, never a 200. And the bump itself runs on the
480// request's context, so a request that ends while it reads the serial does
481// not write it.
482// Mutations, each red in its own case: drop any one of the four ended()
483// checks; drop the cancel on a published step; write the audit row after
484// the creates ("the audit row is down before the request"); call
485// s.bumpSerial instead of announceDBRead ("ended during the serial read":
486// the detached write lands).
487func TestDBReadEndedDuringAdmissionWritesOnlyTheCancel(t *testing.T) {
488 t.Run("the audit row is down before the request", func(t *testing.T) {
489 fx := newDBFFixture(t, true)
490 auditedFirst := false
491 fx.srv.cfg.Releases = &dbfPutHook{Store: fx.rel, suffix: "/request.json", hook: func() {
492 auditedFirst = dbReadAudited(t, fx.st)
493 }}
494 fx.srv.dbf.pickup = 100 * time.Millisecond
495 body, _ := queryBody(t, "SELECT 1")
496 res := fx.openRead(t, dbfHost, fx.member, body)
497 io.Copy(io.Discard, res.Body)
498 res.Body.Close()
499 if !auditedFirst {
500 t.Fatal("request.json was written before the audit row")
501 }
502 })
503 for _, c := range []struct {
504 step string
505 audited bool
506 requests, queued, cancels int
507 }{
508 {"audit", false, 0, 0, 0},
509 {"request", true, 0, 0, 0},
510 {"queue", true, 1, 0, 1},
511 {"serial", true, 1, 1, 1},
512 } {
513 t.Run("ended before "+c.step, func(t *testing.T) {
514 fx := newDBFFixture(t, true)
515 _, serialBefore, _ := fx.rel.Get(context.Background(), release.SerialKey)
516 ctx, cancel := context.WithCancel(context.Background())
517 defer cancel()
518 fx.srv.dbf.beforeWrite = func(step string) {
519 if step == c.step {
520 cancel()
521 }
522 }
523 rec := fx.dbfServe(t, ctx, "SELECT 1")
524 // The ended path writes no response (nobody is there to read
525 // it), and a recorder never written reports 200: read the body.
526 if strings.Contains(rec.Body.String(), "admitted") {
527 t.Fatalf("an ended request was admitted: %s", rec.Body)
528 }
529 if _, serialAfter, _ := fx.rel.Get(context.Background(), release.SerialKey); serialAfter != serialBefore {
530 t.Fatal("the serial was bumped after the request ended")
531 }
532 requests, queued, cancels := dbfBucket(t, fx.rel)
533 if got := dbReadAudited(t, fx.st); got != c.audited || requests != c.requests || queued != c.queued || cancels != c.cancels {
534 t.Fatalf("audited %v, requests %d, queue entries %d, cancels %d", got, requests, queued, cancels)
535 }
536 })
537 }
538 // Over the real DirStore, whose Put ignores its context (Astra round 1,
539 // P2): only the read of the serial is hooked, so nothing but the bump's
540 // own check can stop the write. The request is then re-checked after the
541 // bump: the read was published, so it gets its cancel and no 200.
542 t.Run("ended during the serial read", func(t *testing.T) {
543 fx := newDBFFixture(t, true)
544 _, serialBefore, _ := fx.rel.Get(context.Background(), release.SerialKey)
545 ctx, cancel := context.WithCancel(context.Background())
546 defer cancel()
547 fx.srv.cfg.Releases = &dbfGetHook{Store: fx.rel, key: release.SerialKey, onGet: cancel}
548 rec := fx.dbfServe(t, ctx, "SELECT 1")
549 if _, serialAfter, _ := fx.rel.Get(context.Background(), release.SerialKey); serialAfter != serialBefore {
550 t.Fatal("the serial was written after the request ended during the bump")
551 }
552 if strings.Contains(rec.Body.String(), "admitted") {
553 t.Fatalf("a request that ended during the bump was admitted: %s", rec.Body)
554 }
555 if requests, queued, cancels := dbfBucket(t, fx.rel); requests != 1 || queued != 1 || cancels != 1 {
556 t.Fatalf("requests %d, queue entries %d, cancels %d", requests, queued, cancels)
557 }
558 })
559}
560
561// dbfGetHook runs onGet once on a read of key and is otherwise the store
562// beneath it, its context handling included: a DirStore's Put ignores its
563// context, as CARLOS_DEPLOYMENT_DIR's store does in production.
564type dbfGetHook struct {
565 release.Store
566 key string
567 onGet func()
568 once sync.Once
569}
570
571func (h *dbfGetHook) Get(ctx context.Context, key string) ([]byte, string, error) {
572 if key == h.key {
573 h.once.Do(h.onGet)
574 }
575 return h.Store.Get(ctx, key)
576}
577
578// Plan review finding 13: a body delivered slowly is cut at s.dbf.preHeader,
579// well inside backhaul's 30 s header wait, and admits nothing.
580// Mutation: drop the preHeader read deadline, keeping only the bound's (red:
581// the connection is still open at 3 s).
582func TestDBReadStalledBodyIsCutAtThePreHeaderBound(t *testing.T) {
583 fx := newDBFFixture(t, true)
584 fx.srv.dbf.preHeader = 300 * time.Millisecond
585 conn, err := net.Dial("tcp", strings.TrimPrefix(fx.ts.URL, "http://"))
586 if err != nil {
587 t.Fatal(err)
588 }
589 defer conn.Close()
590 path := strings.TrimPrefix(fx.readURL(dbfHost), fx.ts.URL)
591 fmt.Fprintf(conn, "POST %s HTTP/1.1\r\nHost: console\r\nAuthorization: Bearer %s\r\n"+
592 "Content-Type: application/json\r\nContent-Length: 4096\r\n\r\n{\"op\":", path, fx.member)
593 conn.SetReadDeadline(time.Now().Add(3 * time.Second))
594 start := time.Now()
595 _, err = io.ReadAll(conn) // a refusal, then the close; or the close alone
596 if ne, ok := err.(net.Error); ok && ne.Timeout() {
597 t.Fatalf("the stalled body still held the request after %s", time.Since(start))
598 }
599 if keys, _ := fx.rel.List(context.Background(), dbreads.Prefix); len(keys) != 0 {
600 t.Fatalf("a stalled body wrote %v", keys)
601 }
602}
603
604// startBox runs plan 1's real agent and worker code in process for uk,
605// passing every 20 ms: a stand-in for the box's serial poll, not acceptance.
606func (fx *dbfFixture) startBox(t *testing.T, replicaDir string) *dbreadstest.Box {
607 t.Helper()
608 box := dbreadstest.Start(t, dbreadstest.Options{Store: fx.rel, Region: "uk", Box: "uk-1", Pub: fx.pub, HibernatePrefix: "instances",
609 OpenReplica: func(context.Context, string) (dbdoor.ReplicaReader, error) {
610 return lsfile.NewReplicaClient(replicaDir), nil
611 }})
612 runBox(t, box)
613 return box
614}
615
616func runBox(t *testing.T, box *dbreadstest.Box) {
617 ctx, cancel := context.WithCancel(context.Background())
618 done := make(chan struct{})
619 go func() {
620 defer close(done)
621 for ctx.Err() == nil {
622 box.Agent.Pass(ctx)
623 time.Sleep(20 * time.Millisecond)
624 }
625 }()
626 t.Cleanup(func() { cancel(); <-done; box.Agent.Wait() })
627}
628
629// openAnswer opens a done result's chunks and summary with the CLI's key.
630func openAnswer(t *testing.T, key *ecdh.PrivateKey, lines []dbreads.Line) ([]byte, dbreads.Summary) {
631 t.Helper()
632 id := lines[0].Admitted
633 res := lines[len(lines)-1].Result
634 if res == nil || res.State != dbreads.StateDone {
635 t.Fatalf("no done result: %+v", lines[len(lines)-1])
636 }
637 var rows bytes.Buffer
638 for _, l := range lines {
639 if l.Chunk == 0 {
640 continue
641 }
642 plain, err := dbreads.OpenChunk(key, id, l.Chunk, dbreads.StreamRows, l.Chunk == res.LastSeq, l.Data)
643 if err != nil {
644 t.Fatalf("chunk %d: %v", l.Chunk, err)
645 }
646 rows.Write(plain)
647 }
648 s, err := dbreads.OpenSummary(key, id, res.Sealed)
649 if err != nil {
650 t.Fatal(err)
651 }
652 return rows.Bytes(), s
653}
654
655// A pull through the console: the route admits it, relays the box's ltx
656// stream and its summary's file list, and audits it with DBF-19; with --at,
657// DBF-20.
658func TestDBReadPullStreamsTheFiles(t *testing.T) {
659 fx := newDBFFixture(t, true)
660 dir := t.TempDir()
661 dbdoortest.Replicate(t, dir, 3)
662 fx.startBox(t, dir)
663 key, _ := dbreads.NewReplyKey()
664 res := fx.openRead(t, dbfHost, fx.member, dbreads.ReadBody{Op: dbreads.OpPull, ReplyKey: dbreads.EncodeReplyKey(key.PublicKey())})
665 lines := readLines(t, res.Body)
666 res.Body.Close()
667 last := lines[len(lines)-1].Result
668 if last == nil || last.State != dbreads.StateDone {
669 t.Fatalf("lines %+v", lines)
670 }
671 s, err := dbreads.OpenSummary(key, lines[0].Admitted, last.Sealed)
672 if err != nil || len(s.Files) == 0 || s.TXID != s.Files[len(s.Files)-1].MaxTXID {
673 t.Fatalf("summary %+v %v", s, err)
674 }
675 h := sha256.New()
676 for _, l := range lines {
677 if l.Chunk == 0 {
678 continue
679 }
680 plain, err := dbreads.OpenChunk(key, lines[0].Admitted, l.Chunk, dbreads.StreamLTX, l.Chunk == last.LastSeq, l.Data)
681 if err != nil {
682 t.Fatalf("chunk %d: %v", l.Chunk, err)
683 }
684 h.Write(plain)
685 }
686 if hex.EncodeToString(h.Sum(nil)) != s.SHA256 {
687 t.Fatal("the pulled bytes do not add up to the summary")
688 }
689 at := time.Now().Add(-time.Second).UTC().Truncate(time.Second)
690 res = fx.openRead(t, dbfHost, fx.member, dbreads.ReadBody{Op: dbreads.OpPull, At: at.Format(time.RFC3339),
691 ReplyKey: dbreads.EncodeReplyKey(key.PublicKey())})
692 readLines(t, res.Body)
693 res.Body.Close()
694 evs, _ := fx.st.Events(OpsAccountID, "shop", 10)
695 for _, want := range []string{fmt.Sprintf(dbfAuditPull, "uk"), fmt.Sprintf(dbfAuditPullAt, "uk", at.Format(time.RFC3339))} {
696 found := false
697 for _, e := range evs {
698 found = found || (e.Action == "db-read" && e.Target == dbfHost && e.Detail == want)
699 }
700 if !found {
701 t.Errorf("no %q audit row in %+v", want, evs)
702 }
703 }
704}
705
706func TestDBReadStreamsAQueryEndToEnd(t *testing.T) {
707 fx := newDBFFixture(t, true)
708 dir := t.TempDir()
709 dbdoortest.Replicate(t, dir, 3)
710 fx.startBox(t, dir)
711 body, key := queryBody(t, "SELECT count(*) AS n FROM t")
712 res := fx.openRead(t, dbfHost, fx.member, body)
713 defer res.Body.Close()
714 if res.StatusCode != http.StatusOK || res.Header.Get("Content-Type") != "application/x-ndjson" ||
715 res.Header.Get("Cache-Control") != "no-store" || res.ContentLength != -1 {
716 t.Fatalf("%d %v", res.StatusCode, res.Header)
717 }
718 lines := readLines(t, res.Body)
719 if len(lines) < 2 || lines[0].Admitted == "" || lines[1].Picked != "uk-1" {
720 t.Fatalf("lines %+v", lines)
721 }
722 rows, _ := openAnswer(t, key, lines)
723 if string(rows) != "n\n30\n" {
724 t.Fatalf("rows %q", rows)
725 }
726}
727
728// T1's second tenant, locally (spec §11.4: the host or prefix taken from
729// anywhere but the record): two instances in uk, each with its own replica
730// at the prefix the box derives for it, served in turn by one box. Each read
731// gets its own rows. Mutation: the agent opens the first host it ever
732// checked (red: the second read gets the first instance's rows).
733// TestDBForwardMinio makes the same check against real S3 with per-region
734// credentials, in CI's MinIO step.
735func TestDBReadEachTenantGetsItsOwnRows(t *testing.T) {
736 fx := newDBFFixture(t, true)
737 const other = "other.example.com"
738 putShopRecord(t, fx.consoleFixture, provision.Record{Host: other, Region: "uk", CreatedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)})
739 root := t.TempDir()
740 dbdoortest.Replicate(t, filepath.Join(root, "instances", dbfHost), 3)
741 dbdoortest.Replicate(t, filepath.Join(root, "instances", other), 1)
742 box := dbreadstest.Start(t, dbreadstest.Options{Store: fx.rel, Region: "uk", Box: "uk-1", Pub: fx.pub, HibernatePrefix: "instances",
743 OpenReplica: func(_ context.Context, prefix string) (dbdoor.ReplicaReader, error) {
744 return lsfile.NewReplicaClient(filepath.Join(root, prefix)), nil
745 }})
746 runBox(t, box)
747 for _, c := range []struct{ host, want string }{{dbfHost, "n\n30\n"}, {other, "n\n10\n"}} {
748 body, key := queryBody(t, "SELECT count(*) AS n FROM t")
749 res := fx.openRead(t, c.host, fx.member, body)
750 lines := readLines(t, res.Body)
751 res.Body.Close()
752 if rows, _ := openAnswer(t, key, lines); string(rows) != c.want {
753 t.Fatalf("%s: %q, want %q", c.host, rows, c.want)
754 }
755 }
756}
757
758// No box: wait lines while the console waits, then ended not-picked after
759// the pickup window, a cancel, and the slot free again.
760func TestDBReadEndsNotPickedAndCancels(t *testing.T) {
761 fx := newDBFFixture(t, true)
762 // issued_at is truncated to the second (production's semantics), so the
763 // pickup window seen from admission is pickup minus up to a second: 1.4 s
764 // leaves at least 400 ms, eight wait periods (plan review finding 30).
765 fx.srv.dbf.poll, fx.srv.dbf.wait, fx.srv.dbf.pickup = 10*time.Millisecond, 50*time.Millisecond, 1400*time.Millisecond
766 body, _ := queryBody(t, "SELECT 1")
767 res := fx.openRead(t, dbfHost, fx.member, body)
768 lines := readLines(t, res.Body)
769 res.Body.Close()
770 waits := 0
771 for _, l := range lines {
772 if l.Wait == dbreads.WaitPickup {
773 waits++
774 }
775 }
776 last := lines[len(lines)-1]
777 if waits < 3 || last.Ended != dbreads.EndedNotPicked || last.Result != nil {
778 t.Fatalf("lines %+v", lines)
779 }
780 if _, _, err := fx.rel.Get(context.Background(), dbreads.CancelKey(lines[0].Admitted)); err != nil {
781 t.Fatalf("no cancel: %v", err)
782 }
783}
784
785// T7, the console's half: admitted leaves with the headers, and every line
786// is flushed as it is written.
787func TestDBReadLinesAreFlushedAsWritten(t *testing.T) {
788 fx := newDBFFixture(t, true)
789 // At least a second of pickup is left after issued_at's truncation: room
790 // for the five lines below at 100 ms each (plan review finding 30).
791 fx.srv.dbf.poll, fx.srv.dbf.wait, fx.srv.dbf.pickup = 10*time.Millisecond, 100*time.Millisecond, 2*time.Second
792 body, _ := queryBody(t, "SELECT 1")
793 start := time.Now()
794 res := fx.openRead(t, dbfHost, fx.member, body)
795 defer res.Body.Close()
796 br := bufio.NewReader(res.Body)
797 prev := start
798 for i := 0; i < 5; i++ {
799 if _, err := br.ReadBytes('\n'); err != nil {
800 t.Fatal(err)
801 }
802 if gap := time.Since(prev); gap > 400*time.Millisecond {
803 t.Fatalf("line %d arrived %s after the last: not flushed", i, gap)
804 }
805 prev = time.Now()
806 }
807}
808
809// dbfOpen is one open read: its response, a reader over its lines, and its
810// id ("" when it was refused).
811type dbfOpen struct {
812 res *http.Response
813 br *bufio.Reader
814 id string
815}
816
817func (fx *dbfFixture) openHeld(t *testing.T, base, bearer string) dbfOpen {
818 t.Helper()
819 body, _ := queryBody(t, "SELECT 1")
820 raw, _ := json.Marshal(body)
821 req, _ := http.NewRequest(http.MethodPost, base+"/api/cli/apps/"+release.OpsAccount+"/shop/db/"+dbfHost+"/reads", bytes.NewReader(raw))
822 req.Header.Set("Content-Type", "application/json")
823 req.Header.Set("Authorization", "Bearer "+bearer)
824 res, err := http.DefaultClient.Do(req)
825 if err != nil {
826 t.Fatal(err)
827 }
828 o := dbfOpen{res: res, br: bufio.NewReader(res.Body)}
829 if res.StatusCode == http.StatusOK {
830 line, _ := o.br.ReadBytes('\n')
831 var l dbreads.Line
832 json.Unmarshal(line, &l)
833 o.id = l.Admitted
834 }
835 return o
836}
837
838// untilPicked reads lines until the console has written picked, so the
839// handler is in the owner's phase.
840func (o dbfOpen) untilPicked(t *testing.T) {
841 t.Helper()
842 for {
843 line, err := o.br.ReadBytes('\n')
844 if err != nil {
845 t.Fatalf("%s ended before picked: %v", o.id, err)
846 }
847 var l dbreads.Line
848 json.Unmarshal(line, &l)
849 if l.Picked != "" {
850 return
851 }
852 }
853}
854
855func refusedTooMany(t *testing.T, o dbfOpen, what string) {
856 t.Helper()
857 status, body := refusal(t, o.res)
858 if status != http.StatusTooManyRequests || o.res.Header.Get("Retry-After") == "" || !strings.Contains(body, dbfTooMany) {
859 t.Fatalf("%s: %d %s", what, status, body)
860 }
861}
862
863// admitWithin retries until a read is admitted: a handler frees its slot when
864// it notices its client went, which takes up to a poll.
865func (fx *dbfFixture) admitWithin(t *testing.T, base, bearer string) dbfOpen {
866 t.Helper()
867 deadline := time.Now().Add(5 * time.Second)
868 for {
869 o := fx.openHeld(t, base, bearer)
870 if o.id != "" {
871 return o
872 }
873 o.res.Body.Close()
874 if time.Now().After(deadline) {
875 t.Fatal("a disconnected read still holds its slot")
876 }
877 time.Sleep(20 * time.Millisecond)
878 }
879}
880
881// DBF-9 and T15 (J5; plan review findings 7 and 18): two open reads per
882// person and sixteen per console, held by the open requests. Both of a
883// person's slots come back when both CLIs leave before pickup, and again
884// when both leave after it; the seventeenth read across nine people is
885// refused; each disconnected read writes cancel; and a console restarted
886// under open reads recovers nothing and starts with empty counts.
887// Mutations: drop handleAPIDBRead's defer release (red: the second pair is
888// never admitted); release at the end of admitDBRead (red: the third read of
889// a pair is admitted); skip cancelDBRead on a client that went (red: no
890// cancel); a startup pass that writes under db-reads/ (red: the listing
891// changes across NewServer).
892func TestDBReadAdmissionBounds(t *testing.T) {
893 fx := newDBFFixture(t, true)
894 fx.srv.dbf.poll, fx.srv.dbf.wait, fx.srv.dbf.pickup = 10*time.Millisecond, 50*time.Millisecond, time.Minute
895 base := fx.ts.URL
896 var left []string
897
898 // Before pickup.
899 a, b := fx.openHeld(t, base, fx.member), fx.openHeld(t, base, fx.member)
900 if a.id == "" || b.id == "" {
901 t.Fatal("the first two reads were refused")
902 }
903 refusedTooMany(t, fx.openHeld(t, base, fx.member), "a third read")
904 a.res.Body.Close()
905 b.res.Body.Close()
906 left = append(left, a.id, b.id)
907
908 // Both slots come back; the pair is picked, then both CLIs leave.
909 c, d := fx.admitWithin(t, base, fx.member), fx.admitWithin(t, base, fx.member)
910 refusedTooMany(t, fx.openHeld(t, base, fx.member), "a third read after the first pair left")
911 for _, o := range []dbfOpen{c, d} {
912 scriptFor(t, fx.rel, o.id).picked(time.Now().Add(time.Minute))
913 o.untilPicked(t)
914 o.res.Body.Close()
915 left = append(left, o.id)
916 }
917 e, f := fx.admitWithin(t, base, fx.member), fx.admitWithin(t, base, fx.member)
918 refusedTooMany(t, fx.openHeld(t, base, fx.member), "a third read after the picked pair left")
919 e.res.Body.Close()
920 f.res.Body.Close()
921 left = append(left, e.id, f.id)
922 for _, id := range left {
923 deadline := time.Now().Add(5 * time.Second)
924 for {
925 if _, _, err := fx.rel.Get(context.Background(), dbreads.CancelKey(id)); err == nil {
926 break
927 }
928 if time.Now().After(deadline) {
929 t.Fatalf("%s: no cancel after its CLI left", id)
930 }
931 time.Sleep(20 * time.Millisecond)
932 }
933 }
934
935 // Sixteen per console, across people.
936 var held []dbfOpen
937 people := dbreads.OpenPerConsole/dbreads.OpenPerPerson + 1
938 for i := 0; i < people; i++ {
939 tok := fx.member
940 if i > 0 {
941 addr := fmt.Sprintf("member%d@example.com", i)
942 if err := fx.st.AddMember(OpsAccountID, addr, RoleMember, time.Now()); err != nil {
943 t.Fatal(err)
944 }
945 tok = cliToken(t, fx.consoleFixture, addr, fmt.Sprintf("code-dbf-%d", i))
946 }
947 for j := 0; j < dbreads.OpenPerPerson; j++ {
948 if len(held) == dbreads.OpenPerConsole {
949 refusedTooMany(t, fx.openHeld(t, base, tok), "the seventeenth read on the console")
950 continue
951 }
952 o := fx.admitWithin(t, base, tok)
953 held = append(held, o)
954 }
955 }
956 if len(held) != dbreads.OpenPerConsole {
957 t.Fatalf("held %d reads", len(held))
958 }
959
960 // A restart under open reads: nothing to recover, nothing written, and
961 // the new console's counts start empty.
962 before, _ := fx.rel.List(context.Background(), dbreads.Prefix)
963 srv2, err := NewServer(fx.srv.cfg, fx.st)
964 if err != nil {
965 t.Fatal(err)
966 }
967 srv2.dbf = fx.srv.dbf
968 ts2 := httptest.NewServer(srv2)
969 defer ts2.Close()
970 after, _ := fx.rel.List(context.Background(), dbreads.Prefix)
971 if !slices.Equal(before, after) {
972 t.Fatalf("starting a console wrote under db-reads/: %v", after)
973 }
974 g := fx.openHeld(t, ts2.URL, fx.member)
975 if g.id == "" {
976 status, body := refusal(t, g.res)
977 t.Fatalf("the restarted console refused a read: %d %s", status, body)
978 }
979 g.res.Body.Close()
980 for _, o := range held {
981 o.res.Body.Close()
982 }
983}
984
985// slowStore holds every call whose key contains match until free closes,
986// ignoring its context: the worst store, for plan review finding 12.
987type slowStore struct {
988 release.Store
989 match string
990 delay time.Duration // 0: until free closes
991 free chan struct{}
992}
993
994func (s *slowStore) hold(key string) {
995 if !strings.Contains(key, s.match) {
996 return
997 }
998 if s.delay > 0 {
999 time.Sleep(s.delay)
1000 return
1001 }
1002 <-s.free
1003}
1004
1005func (s *slowStore) Get(ctx context.Context, key string) ([]byte, string, error) {
1006 s.hold(key)
1007 return s.Store.Get(ctx, key)
1008}
1009
1010func (s *slowStore) List(ctx context.Context, prefix string) ([]string, error) {
1011 s.hold(prefix)
1012 return s.Store.List(ctx, prefix)
1013}
1014
1015// Plan review finding 12: a store that stops answering, in either phase,
1016// neither silences the stream nor holds it past the phase's deadline.
1017// Mutations: call the store on r.Context() without dbfCall (red: no wait
1018// line and no end until free closes); drop the pickup deadline context (red:
1019// same); drop the owner's deadline context (red: same in the owner case).
1020func TestDBReadStalledStoreKeepsTheStreamAndItsBound(t *testing.T) {
1021 for _, phase := range []string{"pickup", "owner"} {
1022 t.Run(phase, func(t *testing.T) {
1023 fx := newDBFFixture(t, true)
1024 fx.srv.dbf.poll, fx.srv.dbf.wait, fx.srv.dbf.call = 10*time.Millisecond, 100*time.Millisecond, time.Minute
1025 fx.srv.dbf.pickup, fx.srv.dbf.grace = 1500*time.Millisecond, 0
1026 ss := &slowStore{Store: fx.rel, match: "/picked", free: make(chan struct{})}
1027 want := dbreads.EndedNotPicked
1028 if phase == "owner" {
1029 ss.match, want = "/result.json", dbreads.EndedNoResult
1030 }
1031 defer close(ss.free)
1032 fx.srv.cfg.Releases = ss
1033 o := fx.openHeld(t, fx.ts.URL, fx.member)
1034 defer o.res.Body.Close()
1035 if phase == "owner" {
1036 scriptFor(t, fx.rel, o.id).picked(time.Now().Add(time.Second))
1037 }
1038 start, prev := time.Now(), time.Now()
1039 var last dbreads.Line
1040 for {
1041 line, err := o.br.ReadBytes('\n')
1042 if err != nil {
1043 break
1044 }
1045 if gap := time.Since(prev); gap > 500*time.Millisecond {
1046 t.Fatalf("a %s gap with the store stalled", gap)
1047 }
1048 prev = time.Now()
1049 json.Unmarshal(line, &last)
1050 if time.Since(start) > 5*time.Second {
1051 t.Fatal("the stalled store held the request past its deadline")
1052 }
1053 }
1054 if last.Ended != want {
1055 t.Fatalf("last line %+v, want %s", last, want)
1056 }
1057 })
1058 }
1059}
1060
1061// Plan review finding 12: a relay slower than the time left ends at the
1062// request's bound, without the rest of the chunks and without the result,
1063// though both are in the bucket. issued_at is truncated to the second, so
1064// the bound is 2 to 3 s after the request; relaying all forty chunks takes 6.
1065// Mutation: relay without the owner's deadline, the chunk Gets on
1066// r.Context() and no ownCtx check after them (red: all forty are relayed
1067// and the request lasts 6 s).
1068// Not witnessed alone: the ownCtx check before the result line; dbfCall
1069// already cuts every call after the deadline, so that check guards only a
1070// line write that outlasts it, which TestDBReadResultAfterTheDeadlineIsNotRelayed
1071// forces with a held writer.
1072func TestDBReadSlowRelayEndsAtTheBound(t *testing.T) {
1073 fx := newDBFFixture(t, true)
1074 fx.srv.dbf.poll, fx.srv.dbf.bound, fx.srv.dbf.grace = 10*time.Millisecond, 3*time.Second, 0
1075 fx.srv.cfg.Releases = &slowStore{Store: fx.rel, match: "/out/", delay: 150 * time.Millisecond}
1076 start := time.Now()
1077 o := fx.openHeld(t, fx.ts.URL, fx.member)
1078 defer o.res.Body.Close()
1079 box := scriptFor(t, fx.rel, o.id)
1080 const n = 40
1081 for seq := int64(1); seq <= n; seq++ {
1082 box.chunk(seq, seq == n, "row\n")
1083 }
1084 box.result(n, strings.Repeat("row\n", n))
1085 box.picked(time.Now().Add(time.Minute))
1086 lines := readLines(t, o.br)
1087 chunks := 0
1088 for _, l := range lines {
1089 if l.Result != nil {
1090 t.Fatalf("a result relayed past the bound: %+v", l.Result)
1091 }
1092 if l.Chunk != 0 {
1093 chunks++
1094 }
1095 }
1096 if took := time.Since(start); took > 4500*time.Millisecond || chunks >= n {
1097 t.Fatalf("relayed %d chunks in %s", chunks, took)
1098 }
1099 if last := lines[len(lines)-1]; last.Ended != dbreads.EndedNoResult {
1100 t.Fatalf("last line %+v", last)
1101 }
1102}
1103
1104// scriptedBox writes a read's objects itself: a stand-in for a box, for the
1105// orderings T18 needs. It seals real chunks to the request's reply key.
1106type scriptedBox struct {
1107 t *testing.T
1108 s release.Store
1109 pub *ecdh.PublicKey
1110 id string
1111}
1112
1113func scriptFor(t *testing.T, s release.Store, id string) *scriptedBox {
1114 t.Helper()
1115 raw, _, err := s.Get(context.Background(), dbreads.RequestKey(id))
1116 if err != nil {
1117 t.Fatal(err)
1118 }
1119 var w struct{ Payload string }
1120 json.Unmarshal(raw, &w)
1121 payload, _ := base64.StdEncoding.DecodeString(w.Payload)
1122 var req dbreads.Request
1123 json.Unmarshal(payload, &req)
1124 pub, err := dbreads.ParseReplyKey(req.ReplyKey)
1125 if err != nil {
1126 t.Fatal(err)
1127 }
1128 return &scriptedBox{t: t, s: s, pub: pub, id: id}
1129}
1130
1131func (b *scriptedBox) picked(deadline time.Time) {
1132 p, _ := json.Marshal(dbreads.Picked{Box: "uk-1", Nonce: "n", Deadline: deadline})
1133 b.s.Put(context.Background(), dbreads.PickedKey(b.id), p, "")
1134}
1135
1136func (b *scriptedBox) chunk(seq int64, last bool, plain string) {
1137 sealed, _ := dbreads.SealChunk(b.pub, b.id, seq, dbreads.StreamRows, last, []byte(plain))
1138 b.s.Put(context.Background(), dbreads.ChunkKey(b.id, seq), sealed, "")
1139}
1140
1141func (b *scriptedBox) result(last int64, rows string) {
1142 sum := sha256.Sum256([]byte(rows))
1143 sealed, _ := dbreads.SealSummary(b.pub, b.id, dbreads.Summary{TXID: 9, Bytes: int64(len(rows)), SHA256: hex.EncodeToString(sum[:])})
1144 r, _ := json.Marshal(dbreads.Result{State: dbreads.StateDone, LastSeq: last, Box: "uk-1", Sealed: sealed})
1145 b.s.Put(context.Background(), dbreads.ResultKey(b.id), r, "")
1146}
1147
1148// dbReadHookStore runs once on the console's first listing of a read's
1149// chunks, after the listing is taken: a box committing between the console's
1150// listing and its next result read. It also records, in order, each result
1151// read and each chunk listing. (hookStore is temphook_test.go's: plan review
1152// finding 2.)
1153type dbReadHookStore struct {
1154 release.Store
1155 once sync.Once
1156 hook func()
1157 mu sync.Mutex
1158 ops []string
1159}
1160
1161func (h *dbReadHookStore) record(op string) {
1162 h.mu.Lock()
1163 defer h.mu.Unlock()
1164 h.ops = append(h.ops, op)
1165}
1166
1167func (h *dbReadHookStore) Get(ctx context.Context, key string) ([]byte, string, error) {
1168 if strings.HasSuffix(key, "/result.json") {
1169 h.record("result")
1170 }
1171 return h.Store.Get(ctx, key)
1172}
1173
1174func (h *dbReadHookStore) List(ctx context.Context, prefix string) ([]string, error) {
1175 keys, err := h.Store.List(ctx, prefix)
1176 if strings.HasSuffix(prefix, "/out/") {
1177 h.record("list")
1178 h.once.Do(h.hook)
1179 }
1180 return keys, err
1181}
1182
1183// T18 (codex 20): the box commits its last chunk and result.json between the
1184// console's listing and its result read, in both orders. The result line
1185// never comes before every chunk through last_seq, the read succeeds, and in
1186// every round the console reads result.json before it lists the chunks.
1187// Mutations, each red on its own (plan review finding 20): list out/ before
1188// reading result.json (the operation-order check), or write the result line
1189// without waiting for next > last_seq (the chunk-count check).
1190func TestDBReadResultWaitsForItsLastChunk(t *testing.T) {
1191 for _, resultFirst := range []bool{false, true} {
1192 fx := newDBFFixture(t, true)
1193 fx.srv.dbf.poll = 10 * time.Millisecond
1194 hs := &dbReadHookStore{Store: fx.rel}
1195 fx.srv.cfg.Releases = hs
1196 body, key := queryBody(t, "SELECT 1")
1197 res := fx.openRead(t, dbfHost, fx.member, body)
1198 br := bufio.NewReader(res.Body)
1199 first, _ := br.ReadBytes('\n')
1200 var adm dbreads.Line
1201 json.Unmarshal(first, &adm)
1202 box := scriptFor(t, fx.rel, adm.Admitted)
1203 hs.hook = func() {
1204 if resultFirst {
1205 box.result(2, "a\nb\n")
1206 box.chunk(2, true, "b\n")
1207 } else {
1208 box.chunk(2, true, "b\n")
1209 box.result(2, "a\nb\n")
1210 }
1211 }
1212 box.chunk(1, false, "a\n")
1213 box.picked(time.Now().Add(time.Minute))
1214 lines := append([]dbreads.Line{adm}, readLines(t, br)...)
1215 res.Body.Close()
1216 chunks := 0
1217 for _, l := range lines {
1218 if l.Chunk != 0 {
1219 chunks++
1220 }
1221 if l.Result != nil && chunks != 2 {
1222 t.Fatalf("result-first %v: the result line came after %d chunks", resultFirst, chunks)
1223 }
1224 }
1225 if rows, _ := openAnswer(t, key, lines); string(rows) != "a\nb\n" {
1226 t.Fatalf("rows %q", rows)
1227 }
1228 hs.mu.Lock()
1229 ops := append([]string(nil), hs.ops...)
1230 hs.mu.Unlock()
1231 for i, op := range ops {
1232 if op == "list" && (i == 0 || ops[i-1] != "result") {
1233 t.Fatalf("result-first %v: a chunk listing not preceded by its round's result read: %v", resultFirst, ops)
1234 }
1235 }
1236 }
1237}
1238
1239// dbfHideStore leaves chunk seq out of the first hide listings of a read's
1240// chunks: a listing that lags the box's writes.
1241type dbfHideStore struct {
1242 release.Store
1243 seq int64
1244 mu sync.Mutex
1245 hide int
1246}
1247
1248func (h *dbfHideStore) List(ctx context.Context, prefix string) ([]string, error) {
1249 keys, err := h.Store.List(ctx, prefix)
1250 if !strings.HasSuffix(prefix, "/out/") {
1251 return keys, err
1252 }
1253 h.mu.Lock()
1254 defer h.mu.Unlock()
1255 if h.hide == 0 {
1256 return keys, err
1257 }
1258 h.hide--
1259 var out []string
1260 for _, k := range keys {
1261 if seq, ok := dbreads.ParseChunkKey(k, prefix); !ok || seq != h.seq {
1262 out = append(out, k)
1263 }
1264 }
1265 return out, err
1266}
1267
1268// T18's completeness guard on its own (plan review finding 31): result.json
1269// says last_seq 2 from the first round, and the first three listings miss
1270// chunk 2. The result line waits until chunk 2 is relayed. Mutation: write
1271// the result line without waiting for next > res.LastSeq (red: the result
1272// comes after one chunk).
1273func TestDBReadResultWaitsForAChunkTheListingMissed(t *testing.T) {
1274 fx := newDBFFixture(t, true)
1275 fx.srv.dbf.poll = 10 * time.Millisecond
1276 fx.srv.cfg.Releases = &dbfHideStore{Store: fx.rel, seq: 2, hide: 3}
1277 body, key := queryBody(t, "SELECT 1")
1278 res := fx.openRead(t, dbfHost, fx.member, body)
1279 defer res.Body.Close()
1280 br := bufio.NewReader(res.Body)
1281 first, _ := br.ReadBytes('\n')
1282 var adm dbreads.Line
1283 json.Unmarshal(first, &adm)
1284 box := scriptFor(t, fx.rel, adm.Admitted)
1285 box.chunk(1, false, "a\n")
1286 box.chunk(2, true, "b\n")
1287 box.result(2, "a\nb\n")
1288 box.picked(time.Now().Add(time.Minute))
1289 lines := append([]dbreads.Line{adm}, readLines(t, br)...)
1290 chunks := 0
1291 for _, l := range lines {
1292 if l.Chunk != 0 {
1293 chunks++
1294 }
1295 if l.Result != nil && chunks != 2 {
1296 t.Fatalf("the result line came after %d chunks", chunks)
1297 }
1298 }
1299 if rows, _ := openAnswer(t, key, lines); string(rows) != "a\nb\n" {
1300 t.Fatalf("rows %q", rows)
1301 }
1302}
1303
1304// dbfHeldWriter is a ResponseWriter that holds the write of a line
1305// containing holdOn until until: a client that takes one line slowly. A
1306// stand-in for a network client, in a unit test.
1307type dbfHeldWriter struct {
1308 header http.Header
1309 holdOn string
1310 until time.Time
1311 mu sync.Mutex
1312 buf bytes.Buffer
1313}
1314
1315func (w *dbfHeldWriter) Header() http.Header { return w.header }
1316func (w *dbfHeldWriter) WriteHeader(int) {}
1317func (w *dbfHeldWriter) Flush() {}
1318
1319func (w *dbfHeldWriter) Write(p []byte) (int, error) {
1320 if bytes.Contains(p, []byte(w.holdOn)) {
1321 time.Sleep(time.Until(w.until))
1322 }
1323 w.mu.Lock()
1324 defer w.mu.Unlock()
1325 return w.buf.Write(p)
1326}
1327
1328// Plan review finding 35: the owner's deadline holds for the result line
1329// too. Every chunk and result.json are in the bucket, but the client takes
1330// the last chunk's line until past the deadline: the read ends no-result,
1331// and the result is not relayed. Mutation: drop the ownCtx check before the
1332// result line (red: the result is relayed).
1333func TestDBReadResultAfterTheDeadlineIsNotRelayed(t *testing.T) {
1334 fx := newDBFFixture(t, true)
1335 fx.srv.dbf.poll, fx.srv.dbf.grace = 10*time.Millisecond, 0
1336 key, err := dbreads.NewReplyKey()
1337 if err != nil {
1338 t.Fatal(err)
1339 }
1340 id, err := dbreads.MintID()
1341 if err != nil {
1342 t.Fatal(err)
1343 }
1344 box := &scriptedBox{t: t, s: fx.rel, pub: key.PublicKey(), id: id}
1345 deadline := time.Now().Add(800 * time.Millisecond)
1346 box.chunk(1, true, "a\n")
1347 box.result(1, "a\n")
1348 box.picked(deadline)
1349 w := &dbfHeldWriter{header: http.Header{}, holdOn: `"chunk":1,`, until: deadline.Add(200 * time.Millisecond)}
1350 r := httptest.NewRequest(http.MethodPost, "/", nil)
1351 fx.srv.streamDBRead(w, r, http.NewResponseController(w), dbReadAdmitted{id: id, region: "uk", issued: time.Now().UTC().Truncate(time.Second)})
1352 w.mu.Lock()
1353 lines := readLines(t, bytes.NewReader(w.buf.Bytes()))
1354 w.mu.Unlock()
1355 chunks := 0
1356 for _, l := range lines {
1357 if l.Result != nil {
1358 t.Fatalf("a result relayed past the owner's deadline: %+v", l.Result)
1359 }
1360 if l.Chunk != 0 {
1361 chunks++
1362 }
1363 }
1364 if last := lines[len(lines)-1]; chunks != 1 || last.Ended != dbreads.EndedNoResult {
1365 t.Fatalf("%d chunks, last line %+v", chunks, last)
1366 }
1367}
1368
1369// dbfDeadlineListener records, for every read deadline the server sets on
1370// its connections, how far ahead it was set.
1371type dbfDeadlineListener struct {
1372 net.Listener
1373 mu sync.Mutex
1374 ahead []time.Duration
1375}
1376
1377type dbfDeadlineConn struct {
1378 net.Conn
1379 l *dbfDeadlineListener
1380}
1381
1382func (l *dbfDeadlineListener) Accept() (net.Conn, error) {
1383 c, err := l.Listener.Accept()
1384 if err != nil {
1385 return nil, err
1386 }
1387 return dbfDeadlineConn{Conn: c, l: l}, nil
1388}
1389
1390func (c dbfDeadlineConn) SetReadDeadline(t time.Time) error {
1391 if !t.IsZero() {
1392 c.l.mu.Lock()
1393 c.l.ahead = append(c.l.ahead, time.Until(t))
1394 c.l.mu.Unlock()
1395 }
1396 return c.Conn.SetReadDeadline(t)
1397}
1398
1399// Astra round 1, P1: complete query JSON followed by padding, with the rest
1400// of the declared body withheld. A decoder that stops after one JSON value
1401// would admit the read, and the server would later drain the tail under the
1402// extended read deadline. The whole body is read under s.dbf.preHeader
1403// instead, so the request is cut there and nothing is published.
1404// Mutation: decode one value without reading to EOF (red: the read is
1405// queued and the connection is still held at 3 s).
1406func TestDBReadWithheldBodyTailIsCutAtThePreHeaderBound(t *testing.T) {
1407 fx := newDBFFixture(t, true)
1408 fx.srv.dbf.preHeader = 300 * time.Millisecond
1409 body, _ := queryBody(t, "SELECT 1")
1410 raw, _ := json.Marshal(body)
1411 conn, err := net.Dial("tcp", strings.TrimPrefix(fx.ts.URL, "http://"))
1412 if err != nil {
1413 t.Fatal(err)
1414 }
1415 defer conn.Close()
1416 path := strings.TrimPrefix(fx.readURL(dbfHost), fx.ts.URL)
1417 fmt.Fprintf(conn, "POST %s HTTP/1.1\r\nHost: console\r\nAuthorization: Bearer %s\r\n"+
1418 "Content-Type: application/json\r\nContent-Length: 16384\r\n\r\n%s%s", path, fx.member, raw, strings.Repeat(" ", 64))
1419 conn.SetReadDeadline(time.Now().Add(3 * time.Second))
1420 start := time.Now()
1421 _, err = io.ReadAll(conn)
1422 if ne, ok := err.(net.Error); ok && ne.Timeout() {
1423 t.Fatalf("the withheld body tail still held the request after %s", time.Since(start))
1424 }
1425 if keys, _ := fx.rel.List(context.Background(), dbreads.Prefix); len(keys) != 0 {
1426 t.Fatalf("a request with a withheld body tail wrote %v", keys)
1427 }
1428}
1429
1430// A body holding more than one JSON value is refused, not half-read.
1431func TestDBReadRefusesTrailingJSON(t *testing.T) {
1432 fx := newDBFFixture(t, true)
1433 body, _ := queryBody(t, "SELECT 1")
1434 raw, _ := json.Marshal(body)
1435 req, _ := http.NewRequest(http.MethodPost, fx.readURL(dbfHost), strings.NewReader(string(raw)+`{"op":"query"}`))
1436 req.Header.Set("Content-Type", "application/json")
1437 req.Header.Set("Authorization", "Bearer "+fx.member)
1438 res, err := http.DefaultClient.Do(req)
1439 if err != nil {
1440 t.Fatal(err)
1441 }
1442 if status, out := refusal(t, res); status != http.StatusBadRequest {
1443 t.Fatalf("%d %s", status, out)
1444 }
1445 if keys, _ := fx.rel.List(context.Background(), dbreads.Prefix); len(keys) != 0 {
1446 t.Fatalf("a body with trailing JSON wrote %v", keys)
1447 }
1448}
1449
1450// Plan review findings 13 and 38: admission reads under s.dbf.preHeader,
1451// and once admitted the read deadline moves out to the read's bound plus a
1452// minute. Observed on the connection itself: once a body has been read to
1453// its end, net/http's background read clears the read deadline on its own,
1454// so a stream that outlives ReadTimeout does not show the extension.
1455// Mutations: drop the extension (red: no deadline past 22 minutes); drop the
1456// preHeader deadline (red: none between 15 and 20 s; the server's own are
1457// 10 s and 5 minutes).
1458func TestDBReadSetsBothReadDeadlines(t *testing.T) {
1459 fx := newDBFFixture(t, true)
1460 fx.srv.dbf.poll, fx.srv.dbf.pickup = 10*time.Millisecond, 1500*time.Millisecond
1461 raw, err := net.Listen("tcp", "127.0.0.1:0")
1462 if err != nil {
1463 t.Fatal(err)
1464 }
1465 ln := &dbfDeadlineListener{Listener: raw}
1466 hs := NewHTTPServer(fx.srv)
1467 go hs.Serve(ln)
1468 t.Cleanup(func() { hs.Close() })
1469 body, _ := queryBody(t, "SELECT 1")
1470 rawBody, _ := json.Marshal(body)
1471 req, _ := http.NewRequest(http.MethodPost, "http://"+raw.Addr().String()+strings.TrimPrefix(fx.readURL(dbfHost), fx.ts.URL), bytes.NewReader(rawBody))
1472 req.Header.Set("Content-Type", "application/json")
1473 req.Header.Set("Authorization", "Bearer "+fx.member)
1474 res, err := http.DefaultClient.Do(req)
1475 if err != nil {
1476 t.Fatal(err)
1477 }
1478 io.Copy(io.Discard, res.Body)
1479 res.Body.Close()
1480 ln.mu.Lock()
1481 defer ln.mu.Unlock()
1482 var pre, extended bool
1483 for _, d := range ln.ahead {
1484 pre = pre || (d > 15*time.Second && d <= 20*time.Second)
1485 extended = extended || d > 22*time.Minute
1486 }
1487 if !pre || !extended {
1488 t.Fatalf("read deadlines set this far ahead: %v", ln.ahead)
1489 }
1490}
1491
1492// Review Focus 3: a picked deadline far in the future does not hold the
1493// request past its own bound.
1494func TestDBReadRequestBoundClampsAForgedDeadline(t *testing.T) {
1495 fx := newDBFFixture(t, true)
1496 fx.srv.dbf.poll, fx.srv.dbf.bound = 10*time.Millisecond, 1500*time.Millisecond
1497 body, _ := queryBody(t, "SELECT 1")
1498 res := fx.openRead(t, dbfHost, fx.member, body)
1499 br := bufio.NewReader(res.Body)
1500 first, _ := br.ReadBytes('\n')
1501 var adm dbreads.Line
1502 json.Unmarshal(first, &adm)
1503 scriptFor(t, fx.rel, adm.Admitted).picked(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC))
1504 done := make(chan []dbreads.Line)
1505 go func() { done <- readLines(t, br) }()
1506 select {
1507 case lines := <-done:
1508 if last := lines[len(lines)-1]; last.Ended != dbreads.EndedNoResult {
1509 t.Fatalf("last line %+v", last)
1510 }
1511 case <-time.After(5 * time.Second):
1512 t.Fatal("the request outlived its bound")
1513 }
1514 res.Body.Close()
1515}
1516
1517// Review Focus 4: a result.json that is not a result is never relayed.
1518func TestDBReadIgnoresAMalformedResult(t *testing.T) {
1519 fx := newDBFFixture(t, true)
1520 fx.srv.dbf.poll, fx.srv.dbf.grace = 10*time.Millisecond, 0
1521 body, _ := queryBody(t, "SELECT 1")
1522 res := fx.openRead(t, dbfHost, fx.member, body)
1523 br := bufio.NewReader(res.Body)
1524 first, _ := br.ReadBytes('\n')
1525 var adm dbreads.Line
1526 json.Unmarshal(first, &adm)
1527 box := scriptFor(t, fx.rel, adm.Admitted)
1528 box.picked(time.Now().Add(500 * time.Millisecond))
1529 fx.rel.Put(context.Background(), dbreads.ResultKey(adm.Admitted), []byte(`{"state":"maybe","last_seq":-1}`), "")
1530 lines := readLines(t, br)
1531 res.Body.Close()
1532 for _, l := range lines {
1533 if l.Result != nil {
1534 t.Fatalf("a malformed result was relayed: %+v", l.Result)
1535 }
1536 }
1537 if last := lines[len(lines)-1]; last.Ended != dbreads.EndedNoResult {
1538 t.Fatalf("last line %+v", last)
1539 }
1540}
1541
1542// strictStore fails the test on any delete or replacing write under
1543// db-reads/ (T16, the console's half).
1544type strictStore struct {
1545 release.Store
1546 t *testing.T
1547}
1548
1549func (s strictStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
1550 if strings.HasPrefix(key, dbreads.Prefix) && ifMatch != "" {
1551 s.t.Errorf("a replacing write of %s", key)
1552 }
1553 return s.Store.Put(ctx, key, body, ifMatch)
1554}
1555
1556func (s strictStore) Delete(ctx context.Context, key string) error {
1557 if strings.HasPrefix(key, dbreads.Prefix) {
1558 s.t.Errorf("a delete of %s", key)
1559 }
1560 return s.Store.Delete(ctx, key)
1561}
1562
1563// T16 (codex 4, 13, 14): a box that writes after the request ended changes
1564// nothing any reader sees, and the console never deletes or replaces.
1565func TestDBReadLateWritesChangeNothing(t *testing.T) {
1566 fx := newDBFFixture(t, true)
1567 fx.srv.cfg.Releases = strictStore{Store: fx.rel, t: t}
1568 fx.srv.dbf.poll, fx.srv.dbf.grace = 10*time.Millisecond, 0
1569 body, _ := queryBody(t, "SELECT 1")
1570 res := fx.openRead(t, dbfHost, fx.member, body)
1571 br := bufio.NewReader(res.Body)
1572 first, _ := br.ReadBytes('\n')
1573 var adm dbreads.Line
1574 json.Unmarshal(first, &adm)
1575 box := scriptFor(t, fx.rel, adm.Admitted)
1576 box.picked(time.Now().Add(200 * time.Millisecond))
1577 lines := readLines(t, br) // ends no-result at the deadline
1578 res.Body.Close()
1579 box.chunk(1, true, "late\n")
1580 box.result(1, "late\n")
1581 if last := lines[len(lines)-1]; last.Ended != dbreads.EndedNoResult {
1582 t.Fatalf("last line %+v", last)
1583 }
1584 keys, _ := fx.rel.List(context.Background(), dbreads.Prefix+adm.Admitted+"/")
1585 for _, k := range keys {
1586 if strings.HasSuffix(k, "/cancel") {
1587 return
1588 }
1589 }
1590 t.Fatalf("no cancel among %v", keys)
1591}
1592