carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// dbforward.go is the CLI's half of a forwarded `carlos db query` and
2// `carlos db pull` (spec
3// docs/superpowers/specs/2026-10-22-db-region-forward-design.md §5.5, §6):
4// when the db door answers with the forward marker, the CLI makes an
5// ephemeral key, holds one request to the console's reads route open, keeps
6// every sealed chunk (in memory for a query, spooled to a private directory
7// for a pull), and prints or saves nothing until a done result's summary
8// checks out against the bytes (codex 6).
9package main
10
11import (
12 "bufio"
13 "bytes"
14 "context"
15 "crypto/ecdh"
16 "crypto/sha256"
17 "encoding/hex"
18 "encoding/json"
19 "errors"
20 "fmt"
21 "io"
22 "net/http"
23 "net/url"
24 "os"
25 "path/filepath"
26 "strconv"
27 "strings"
28 "time"
29 "unicode/utf8"
30
31 "github.com/benbjohnson/litestream"
32 lsfile "github.com/benbjohnson/litestream/file"
33 "github.com/superfly/ltx"
34
35 "github.com/carlosframework/platform/internal/dbdoor"
36 "github.com/carlosframework/platform/internal/dbreads"
37)
38
39// The forwarded read's CLI sentences (copy record DBF-3 to DBF-8, DBF-11 to
40// DBF-13, DBF-15, DBF-16, DBF-32), approved by Luna on 2026-10-23.
41const (
42 dbfNotPicked = "no server in region %s started the read within 60 seconds. try again in a few minutes." // DBF-3
43 dbfTooLarge = "the database for %s is %s once restored. carlos db can query at most %s in region %s." // DBF-4
44 dbfNoRoom = "the server in region %s does not have room to restore %s's database right now. try again later." // DBF-5
45 dbfOutputCap = "the result was cut off at 64 MB. narrow the query with WHERE or LIMIT and run it again." // DBF-6
46 dbfTimeout = "the query ran for 5 minutes and was stopped. narrow it and run it again." // DBF-7
47 dbfNoPull = "%s keeps its data in region %s. carlos db pull cannot fetch a copy from there yet. carlos db query can read it." // DBF-8
48 dbfReading = "reading a copy of %s's database in region %s." // DBF-11
49 dbfUnfinished = "the read of %s's database in region %s did not finish, so nothing was printed. try again." // DBF-12
50 dbfUnchecked = "the answer from region %s was invalid, so carlos db printed nothing. try again." // DBF-13
51 dbfChanged = "%s changed while the read was waiting. try again." // DBF-15
52 dbfMovingCLI = "%s is moving between regions. read its database once the move has finished." // DBF-16
53 dbfNotUTF8 = "this query cannot be sent to region %s, because it contains invalid UTF-8 or the replacement character U+FFFD. fix the query and run it again." // DBF-32
54)
55
56// The forwarded pull's CLI sentences (copy record DBF-17, DBF-18, DBF-24),
57// approved by Luna on 2026-10-23.
58const (
59 dbfPullTooLarge = "the database for %s has %s of files to fetch. carlos db pull can fetch at most %s from region %s." // DBF-17
60 dbfFetching = "fetching a copy of %s's database from region %s." // DBF-18
61 dbfPullNoRoom = "the server in region %s does not have room to prepare a copy of %s's database right now. try again later." // DBF-24
62)
63
64// errDBForward is the db door's forward marker, handed back by dbRestore so
65// a verb can switch to the forwarded read.
66type errDBForward struct {
67 door *dbdoor.DoorError
68}
69
70func (e *errDBForward) Error() string { return e.door.Error() }
71
72type dbForwardAnswer struct {
73 rows []byte
74 summary dbreads.Summary
75 capped bool
76}
77
78// dbQueryForward is spec §5.5 steps 2 and 8 for a query.
79func dbQueryForward(ctx context.Context, t dbTarget, sqlText string, fwd dbdoor.Forward) error {
80 // json.Marshal would send U+FFFD for bytes that are not UTF-8, and the
81 // box would run SQL nobody typed; the console refuses U+FFFD for the
82 // same reason (plan review finding 36). First, so no other check can
83 // read the bytes differently.
84 if !utf8.ValidString(sqlText) || strings.ContainsRune(sqlText, utf8.RuneError) {
85 return fmt.Errorf(dbfNotUTF8, fwd.Region)
86 }
87 if err := checkQuerySQL(sqlText); err != nil {
88 return err
89 }
90 ctx, cancel := context.WithTimeout(ctx, dbreads.CLIBound)
91 defer cancel()
92 key, err := dbreads.NewReplyKey()
93 if err != nil {
94 return err
95 }
96 at := ""
97 if !t.at.IsZero() {
98 at = t.at.UTC().Format(time.RFC3339Nano)
99 }
100 body, err := json.Marshal(dbreads.ReadBody{Op: dbreads.OpQuery, SQL: sqlText, At: at, ReplyKey: dbreads.EncodeReplyKey(key.PublicKey())})
101 if err != nil {
102 return err
103 }
104 res, err := t.sess.c.doStream(ctx, http.MethodPost, apiPath(t.acct, t.app, "/db/"+url.PathEscape(t.host)+"/reads"),
105 bytes.NewReader(body), int64(len(body)), http.Header{"Content-Type": {"application/json"}})
106 if err != nil {
107 if errors.Is(ctx.Err(), context.Canceled) {
108 return errors.New("stopped")
109 }
110 return fmt.Errorf(dbfUnfinished, t.host, fwd.Region)
111 }
112 defer res.Body.Close()
113 if res.StatusCode != http.StatusOK {
114 raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
115 return apiErrorFromBody(res.StatusCode, raw)
116 }
117 sink := &memSink{}
118 id, result, err := readDBForward(ctx, res.Body, key, t.host, fwd.Region, dbfReading, os.Stderr, sink, newDBFChunkBound(dbreads.MaxOutputBytes, dbreads.ChunkBytes))
119 if err != nil {
120 return err
121 }
122 ans, err := openDBForward(id, key, sink, *result, t.host, fwd.Region)
123 if err != nil {
124 return err
125 }
126 fmt.Fprint(os.Stderr, dbQueryStatus(t.host, &dbdoor.Restored{TXID: ans.summary.TXID, At: ans.summary.At}, t.at))
127 if _, err := stdout.Write(ans.rows); err != nil {
128 return err
129 }
130 if ans.capped {
131 return errors.New(dbfOutputCap)
132 }
133 return nil
134}
135
136// dbfChunkBound is what a reader keeps before a result arrives (plan review
137// finding 37). Every chunk is at least a sealed empty one; only the last
138// may be empty, so nothing may follow an empty chunk; there are at most as
139// many chunks as a read can make (full ones for the payload, plus one per
140// flush interval over the owner's deadline, plus the last); and their
141// plaintext, ciphertext less each seal, stays within the payload cap.
142type dbfChunkBound struct {
143 maxBytes, maxChunks int64
144 n, plain int64
145 sawEmpty bool
146}
147
148func newDBFChunkBound(maxBytes, chunkBytes int64) *dbfChunkBound {
149 return &dbfChunkBound{maxBytes: maxBytes,
150 maxChunks: (maxBytes+chunkBytes-1)/chunkBytes + int64(dbreads.OwnerDeadline/dbreads.ChunkEvery) + 1}
151}
152
153// admit says whether a chunk may be kept; false is DBF-13.
154func (b *dbfChunkBound) admit(sealed []byte) bool {
155 size := int64(len(sealed)) - int64(dbreads.SealOverhead)
156 if size < 0 || b.sawEmpty || b.n >= b.maxChunks || b.plain+size > b.maxBytes {
157 return false
158 }
159 b.n++
160 b.plain += size
161 b.sawEmpty = size == 0
162 return true
163}
164
165// dbChunkSink holds a read's sealed chunks until its result arrives: in
166// memory for a query (at most 64 MiB), in the pull's private directory for a
167// pull (up to its cap).
168type dbChunkSink interface {
169 add(sealed []byte) error
170 count() int
171 chunk(i int) ([]byte, error) // 0-based
172}
173
174type memSink struct{ chunks [][]byte }
175
176func (m *memSink) add(b []byte) error { m.chunks = append(m.chunks, b); return nil }
177func (m *memSink) count() int { return len(m.chunks) }
178func (m *memSink) chunk(i int) ([]byte, error) { return m.chunks[i], nil }
179
180// fileSink spools sealed chunks to dir, one file each, 0600 in a 0700
181// directory the caller removes.
182type fileSink struct {
183 dir string
184 n int
185}
186
187func (f *fileSink) add(b []byte) error {
188 if f.n == 0 {
189 if err := os.MkdirAll(f.dir, 0o700); err != nil {
190 return err
191 }
192 }
193 f.n++
194 return dbPullSpoolWrite(filepath.Join(f.dir, strconv.Itoa(f.n)), b, 0o600)
195}
196
197func (f *fileSink) count() int { return f.n }
198
199func (f *fileSink) chunk(i int) ([]byte, error) {
200 return os.ReadFile(filepath.Join(f.dir, strconv.Itoa(i+1)))
201}
202
203// drop removes chunk i once its bytes are in the files, so the spool and
204// the files are never both whole on the member's disk.
205func (f *fileSink) drop(i int) { os.Remove(filepath.Join(f.dir, strconv.Itoa(i+1))) }
206
207// readDBForward reads the reads route's lines (spec §5.5 steps 6 to 8). It
208// says the reading sentence once admitted, keeps every chunk in sequence in
209// sink (each one checked against bound before the sink keeps anything, plan
210// review finding 37), and returns the read's id and its result unopened.
211func readDBForward(ctx context.Context, body io.Reader, key *ecdh.PrivateKey, host, region, reading string, status io.Writer, sink dbChunkSink, bound *dbfChunkBound) (string, *dbreads.Result, error) {
212 unchecked := fmt.Errorf(dbfUnchecked, region)
213 sc := bufio.NewScanner(body)
214 sc.Buffer(make([]byte, 64<<10), dbreads.MaxLineBytes)
215 var id, ended string
216 for sc.Scan() {
217 var l dbreads.Line
218 if err := json.Unmarshal(sc.Bytes(), &l); err != nil {
219 return "", nil, unchecked
220 }
221 switch {
222 case l.Admitted != "":
223 if id != "" || !dbreads.ValidID(l.Admitted) {
224 return "", nil, unchecked
225 }
226 id = l.Admitted
227 fmt.Fprintln(status, fmt.Sprintf(reading, host, region))
228 case l.Chunk != 0:
229 if id == "" || l.Chunk != int64(sink.count())+1 || !bound.admit(l.Data) {
230 return "", nil, unchecked
231 }
232 if err := sink.add(l.Data); err != nil {
233 return "", nil, err
234 }
235 case l.Result != nil:
236 if id == "" {
237 return "", nil, unchecked
238 }
239 return id, l.Result, nil
240 case l.Ended != "":
241 ended = l.Ended
242 }
243 }
244 if errors.Is(ctx.Err(), context.Canceled) {
245 return "", nil, errors.New("stopped")
246 }
247 if ended == dbreads.EndedNotPicked {
248 return "", nil, fmt.Errorf(dbfNotPicked, region)
249 }
250 return "", nil, fmt.Errorf(dbfUnfinished, host, region)
251}
252
253// openDBForward opens a result: a done one's every chunk and its summary,
254// checked against each other before anything is returned; any other state
255// mapped to its sentence (spec §5.6, §10).
256func openDBForward(id string, key *ecdh.PrivateKey, sink dbChunkSink, res dbreads.Result, host, region string) (dbForwardAnswer, error) {
257 unchecked := fmt.Errorf(dbfUnchecked, region)
258 if res.Valid() != nil {
259 return dbForwardAnswer{}, unchecked
260 }
261 s, err := dbreads.OpenSummary(key, id, res.Sealed)
262 if err != nil {
263 return dbForwardAnswer{}, unchecked
264 }
265 switch res.State {
266 case dbreads.StateDone:
267 if res.LastSeq != int64(sink.count()) {
268 return dbForwardAnswer{}, unchecked
269 }
270 var rows bytes.Buffer
271 for i := 0; i < sink.count(); i++ {
272 c, err := sink.chunk(i)
273 if err != nil {
274 return dbForwardAnswer{}, unchecked
275 }
276 seq := int64(i + 1)
277 plain, err := dbreads.OpenChunk(key, id, seq, dbreads.StreamRows, seq == res.LastSeq, c)
278 if err != nil {
279 return dbForwardAnswer{}, unchecked
280 }
281 rows.Write(plain)
282 }
283 sum := sha256.Sum256(rows.Bytes())
284 if int64(rows.Len()) != s.Bytes || hex.EncodeToString(sum[:]) != s.SHA256 {
285 return dbForwardAnswer{}, unchecked
286 }
287 return dbForwardAnswer{rows: rows.Bytes(), summary: s, capped: res.Code == dbreads.CodeOutputCap}, nil
288 case dbreads.StateRefused:
289 if res.Code == dbreads.CodeMoving {
290 return dbForwardAnswer{}, fmt.Errorf(dbfMovingCLI, host)
291 }
292 return dbForwardAnswer{}, fmt.Errorf(dbfChanged, host)
293 case dbreads.StateFailed:
294 switch res.Code {
295 case dbreads.CodeNothingReplicated:
296 return dbForwardAnswer{}, dbRestoreError(host, dbdoor.ErrNothingReplicated)
297 case dbreads.CodeHistoryGone:
298 if !s.Oldest.IsZero() {
299 return dbForwardAnswer{}, dbRestoreError(host, &dbdoor.BeforeHistoryError{Oldest: s.Oldest})
300 }
301 case dbreads.CodeCheckFailed:
302 return dbForwardAnswer{}, dbRestoreError(host, fmt.Errorf("%w: %s", dbdoor.ErrCheckFailed, s.Error))
303 case dbreads.CodeChangedTwice:
304 return dbForwardAnswer{}, dbRestoreError(host, os.ErrNotExist)
305 case dbreads.CodeTooLarge:
306 return dbForwardAnswer{}, fmt.Errorf(dbfTooLarge, host, humanBytes(&s.Restored), humanBytes(&s.Cap), region)
307 case dbreads.CodeNoRoom:
308 return dbForwardAnswer{}, fmt.Errorf(dbfNoRoom, region, host)
309 case dbreads.CodeSQLError:
310 return dbForwardAnswer{}, errors.New(s.Error)
311 case dbreads.CodeTimeout:
312 return dbForwardAnswer{}, errors.New(dbfTimeout)
313 }
314 }
315 return dbForwardAnswer{}, fmt.Errorf(dbfUnfinished, host, region)
316}
317
318// openDBPull opens a pull's result (spec §6, phase 2): a done one's chunks,
319// opened in order, are the planned files concatenated; they are checked
320// against the summary's count and hash and split by its file list into a
321// litestream file replica under dir, which today's restore and check turn
322// into the copy. Any other state maps to its sentence.
323func openDBPull(ctx context.Context, id string, key *ecdh.PrivateKey, sink dbChunkSink, res dbreads.Result, host, region, dir string) (*dbdoor.Restored, error) {
324 unchecked := fmt.Errorf(dbfUnchecked, region)
325 if res.Valid() != nil {
326 return nil, unchecked
327 }
328 s, err := dbreads.OpenSummary(key, id, res.Sealed)
329 if err != nil {
330 return nil, unchecked
331 }
332 if res.State != dbreads.StateDone {
333 switch res.Code {
334 case dbreads.CodeTooLarge:
335 return nil, fmt.Errorf(dbfPullTooLarge, host, humanBytes(&s.Restored), humanBytes(&s.Cap), region)
336 case dbreads.CodeNoRoom:
337 return nil, fmt.Errorf(dbfPullNoRoom, region, host)
338 }
339 _, err := openDBForward(id, key, sink, res, host, region)
340 return nil, err
341 }
342 // A done pull is the whole answer: no output cap applies to one.
343 if res.Code != "" || res.LastSeq != int64(sink.count()) || checkPullManifest(s) != nil {
344 return nil, unchecked
345 }
346 root := filepath.Join(dir, "replica")
347 h := sha256.New()
348 fi, written := 0, int64(0)
349 var head []byte // the current file's first ltx.HeaderSize bytes
350 var out *os.File
351 defer func() {
352 if out != nil {
353 out.Close()
354 }
355 }()
356 drop, _ := sink.(interface{ drop(int) })
357 for i := 0; i < sink.count(); i++ {
358 // Ctrl-C and the read's deadline stop the rebuild too (Astra round
359 // 1 on plan 3, finding 2); RestoreLocal's reads check ctx after it.
360 if err := ctx.Err(); err != nil {
361 return nil, dbPullStopped(err, host, region)
362 }
363 sealed, err := sink.chunk(i)
364 if err != nil {
365 return nil, unchecked
366 }
367 seq := int64(i + 1)
368 plain, err := dbreads.OpenChunk(key, id, seq, dbreads.StreamLTX, seq == res.LastSeq, sealed)
369 if err != nil {
370 return nil, unchecked
371 }
372 h.Write(plain)
373 for len(plain) > 0 {
374 if fi >= len(s.Files) {
375 return nil, unchecked
376 }
377 f := s.Files[fi]
378 if out == nil {
379 p := litestream.LTXFilePath(root, f.Level, ltx.TXID(f.MinTXID), ltx.TXID(f.MaxTXID))
380 if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
381 return nil, err
382 }
383 if out, err = os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600); err != nil {
384 return nil, err
385 }
386 }
387 n := min(int64(len(plain)), f.Size-written)
388 if _, err := out.Write(plain[:n]); err != nil {
389 return nil, err
390 }
391 if len(head) < ltx.HeaderSize {
392 head = append(head, plain[:min(n, int64(ltx.HeaderSize-len(head)))]...)
393 }
394 plain, written = plain[n:], written+n
395 if written == f.Size {
396 if err := out.Close(); err != nil {
397 out = nil
398 return nil, err
399 }
400 // litestream's restore picks files by name and compacts them
401 // by header: a file whose header is not its manifest entry
402 // would restore as a transaction it does not hold (Astra
403 // round 1 on plan 3, finding 1).
404 var lh ltx.Header
405 if lh.UnmarshalBinary(head) != nil || uint64(lh.MinTXID) != f.MinTXID || uint64(lh.MaxTXID) != f.MaxTXID {
406 out = nil
407 return nil, unchecked
408 }
409 out, fi, written, head = nil, fi+1, 0, nil
410 }
411 }
412 if drop != nil {
413 drop.drop(i)
414 }
415 }
416 if fi != len(s.Files) || hex.EncodeToString(h.Sum(nil)) != s.SHA256 {
417 return nil, unchecked
418 }
419 db := filepath.Join(dir, "replica.db")
420 if err := dbdoor.RestoreLocal(ctx, lsfile.NewReplicaClient(root), db, s.TXID); err != nil {
421 if cerr := ctx.Err(); cerr != nil {
422 return nil, dbPullStopped(cerr, host, region)
423 }
424 return nil, dbRestoreError(host, err)
425 }
426 os.RemoveAll(root)
427 return &dbdoor.Restored{Path: db, Dir: dir, TXID: s.TXID, At: s.At}, nil
428}
429
430// dbPullStopped is a pull that ended on its context: "stopped" for Ctrl-C,
431// DBF-12 for the read's own deadline.
432func dbPullStopped(err error, host, region string) error {
433 if errors.Is(err, context.Canceled) {
434 return errors.New("stopped")
435 }
436 return fmt.Errorf(dbfUnfinished, host, region)
437}
438
439// checkPullManifest is the summary's file list checked whole before any
440// file is created (plan review finding 22): every size a possible LTX file
441// and within the cap, every level one litestream writes, every range a
442// range, no file twice, sizes that sum without overflow to the summary's
443// byte count, and a last file that ends at its transaction.
444func checkPullManifest(s dbreads.Summary) error {
445 bad := errors.New("the file list does not add up")
446 if len(s.Files) == 0 || s.Bytes <= 0 || s.Bytes > dbreads.MaxPullBytes {
447 return bad
448 }
449 type coord struct {
450 level int
451 min, max uint64
452 }
453 seen := make(map[coord]bool, len(s.Files))
454 var sum int64
455 for _, f := range s.Files {
456 c := coord{f.Level, f.MinTXID, f.MaxTXID}
457 switch {
458 case f.Size < ltx.HeaderSize+ltx.TrailerSize, f.Size > dbreads.MaxPullBytes,
459 f.Level < 0, f.Level > litestream.SnapshotLevel,
460 f.MinTXID < 1, f.MaxTXID < f.MinTXID, seen[c],
461 sum > dbreads.MaxPullBytes-f.Size: // sum + f.Size would pass the cap
462 return bad
463 }
464 seen[c] = true
465 sum += f.Size
466 }
467 if sum != s.Bytes || s.Files[len(s.Files)-1].MaxTXID != s.TXID {
468 return bad
469 }
470 return nil
471}
472
473// dbPullSpoolWrite writes one spooled chunk: os.WriteFile, a seam so a test
474// can fail the spool part way (plan review finding 23).
475var dbPullSpoolWrite = os.WriteFile
476
477// dbPullForward is a pull through the forwarded read (spec §6). The spool,
478// the files and the copy live in one private 0700 directory under the system
479// temp dir, removed on every path but success, where the caller removes it
480// after copyOut, as it does today's restore directory.
481func dbPullForward(ctx context.Context, t dbTarget, fwd dbdoor.Forward) (*dbdoor.Restored, error) {
482 ctx, cancel := context.WithTimeout(ctx, dbreads.CLIBound)
483 defer cancel()
484 key, err := dbreads.NewReplyKey()
485 if err != nil {
486 return nil, err
487 }
488 at := ""
489 if !t.at.IsZero() {
490 at = t.at.UTC().Format(time.RFC3339Nano)
491 }
492 body, err := json.Marshal(dbreads.ReadBody{Op: dbreads.OpPull, At: at, ReplyKey: dbreads.EncodeReplyKey(key.PublicKey())})
493 if err != nil {
494 return nil, err
495 }
496 res, err := t.sess.c.doStream(ctx, http.MethodPost, apiPath(t.acct, t.app, "/db/"+url.PathEscape(t.host)+"/reads"),
497 bytes.NewReader(body), int64(len(body)), http.Header{"Content-Type": {"application/json"}})
498 if err != nil {
499 if errors.Is(ctx.Err(), context.Canceled) {
500 return nil, errors.New("stopped")
501 }
502 return nil, fmt.Errorf(dbfUnfinished, t.host, fwd.Region)
503 }
504 defer res.Body.Close()
505 if res.StatusCode != http.StatusOK {
506 raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
507 return nil, apiErrorFromBody(res.StatusCode, raw)
508 }
509 dir, err := os.MkdirTemp("", "carlos-db-")
510 if err != nil {
511 return nil, err
512 }
513 // MkdirTemp already creates 0700; say it anyway, as the in-region
514 // restore does, because the copy's privacy rests on it.
515 if err := os.Chmod(dir, 0o700); err != nil {
516 os.RemoveAll(dir)
517 return nil, err
518 }
519 done := false
520 defer func() {
521 if !done {
522 os.RemoveAll(dir)
523 }
524 }()
525 sink := &fileSink{dir: filepath.Join(dir, "sealed")}
526 id, result, err := readDBForward(ctx, res.Body, key, t.host, fwd.Region, dbfFetching, os.Stderr, sink, newDBFChunkBound(dbreads.MaxPullBytes, dbreads.PullChunkBytes))
527 if err != nil {
528 return nil, err
529 }
530 r, err := openDBPull(ctx, id, key, sink, *result, t.host, fwd.Region, dir)
531 if err != nil {
532 return nil, err
533 }
534 os.RemoveAll(sink.dir)
535 done = true
536 return r, nil
537}
538