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 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "os"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/benbjohnson/litestream"
17 "github.com/superfly/ltx"
18)
19
20// Doer sends one GET to the door for one instance. tail is relative to the
21// instance's door root (ListTail or GetTail); the caller owns the console
22// URL, the bearer and the account segment, which is why the client never
23// sees them. The caller's response body is the client's to close.
24type Doer func(ctx context.Context, tail string, header http.Header) (*http.Response, error)
25
26// Client is the CLI's litestream.ReplicaClient over the db door (spec
27// 2026-09-02 §6, "The read-only replica client"), the way internal/fleetapi
28// implements release.Store over the edge store door: the member has no AWS
29// credentials, by design.
30//
31// It supplies the five methods a restore needs (Type, Init, LTXFiles,
32// OpenLTXFile, SetLogger). The three that mutate a replica return
33// errReadOnly and have no other body. It must never implement
34// litestream.ReplicaClientV3, or Restore would take the v0.3 path.
35type Client struct {
36 handles dbHandleState
37 do Doer
38 logger *slog.Logger
39}
40
41// NewClient returns a read-only replica client that reads through do.
42func NewClient(do Doer) *Client {
43 return &Client{do: do, logger: slog.Default()}
44}
45
46// errReadOnly is not a guard that can be switched off: these three methods
47// have no other body. The two-replicators hazard internal/caps and
48// internal/lease both warn about is unreachable from this client by
49// construction, and a future edit that wanted to reach it would have to add
50// the capability first.
51var errReadOnly = errors.New("dbdoor: this client is read-only; it restores replicas and never writes them")
52
53// DoorError is a refusal from the door, carrying the console's own sentence
54// (its {"error"} body) so the CLI can show it as written.
55type DoorError struct {
56 Status int
57 Message string
58 // RetryAfter is the door's Retry-After, in whole seconds, when it sent
59 // one (a 429 from a full stream bound does); zero otherwise.
60 RetryAfter time.Duration
61 // Forward is the list door's forward marker on a 409 (db region
62 // forward spec §5.5): the instance is readable through the forwarded
63 // read in Forward.Region. nil for every other refusal.
64 Forward *Forward
65}
66
67func (e *DoorError) Error() string {
68 if e.Message == "" {
69 return fmt.Sprintf("the console answered %d", e.Status)
70 }
71 return e.Message
72}
73
74func (c *Client) Type() string { return "carlos-db" }
75
76// Init is a no-op: the transport needs no setup, and litestream requires
77// it to be idempotent.
78func (c *Client) Init(context.Context) error { return nil }
79
80func (c *Client) SetLogger(l *slog.Logger) { c.logger = l }
81
82// LTXFiles lists one level. seek is filtered here (wire specific 3), with
83// litestream's own rule: files whose MinTXID is below seek are skipped.
84// useMetadata is ignored; nothing in a non-timestamp restore reads
85// CreatedAt except debug logs.
86func (c *Client) LTXFiles(ctx context.Context, level int, seek ltx.TXID, _ bool) (ltx.FileIterator, error) {
87 c.handles.listMu.Lock()
88 defer c.handles.listMu.Unlock()
89 files, ok := c.planFiles(level)
90 if !ok {
91 var err error
92 files, err = c.listFiles(ctx, level)
93 if err != nil {
94 return nil, err
95 }
96 }
97 return fileIterator(files, level, seek), nil
98}
99
100func (c *Client) listFiles(ctx context.Context, level int) ([]LTXFile, error) {
101 res, err := c.do(ctx, ListTail(level), http.Header{})
102 if err != nil {
103 return nil, err
104 }
105 defer res.Body.Close()
106 if res.StatusCode != http.StatusOK {
107 return nil, doorError(res)
108 }
109 var list ListResponse
110 if err := json.NewDecoder(io.LimitReader(res.Body, 16<<20)).Decode(&list); err != nil {
111 return nil, fmt.Errorf("dbdoor: unreadable listing for level %d: %w", level, err)
112 }
113 return c.rememberFiles(level, list)
114}
115
116func fileIterator(files []LTXFile, level int, seek ltx.TXID) ltx.FileIterator {
117 infos := make([]*ltx.FileInfo, 0, len(files))
118 for _, f := range files {
119 if ltx.TXID(f.MinTXID) < seek {
120 continue
121 }
122 infos = append(infos, &ltx.FileInfo{
123 Level: level, MinTXID: ltx.TXID(f.MinTXID), MaxTXID: ltx.TXID(f.MaxTXID),
124 Size: f.Size, CreatedAt: f.CreatedAt,
125 })
126 }
127 return ltx.NewFileInfoSliceIterator(infos)
128}
129
130// OpenLTXFile opens one file by coordinate, sending the S3 client's exact
131// Range form. A 404 is os.ErrNotExist (wire specific 5): that is what lets
132// RestoreTo tell a file that vanished under the plan from a refusal.
133func (c *Client) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
134 h := http.Header{}
135 handle, err := c.fileHandle(level, uint64(minTXID), uint64(maxTXID))
136 if err != nil {
137 return nil, err
138 }
139 if handle != "" {
140 h.Set(HandleHeader, handle)
141 }
142 h.Set("Range", RangeHeader(offset, size))
143 res, err := c.do(ctx, GetTail(level, uint64(minTXID), uint64(maxTXID)), h)
144 if err != nil {
145 return nil, err
146 }
147 switch res.StatusCode {
148 case http.StatusOK, http.StatusPartialContent:
149 if handle != "" && res.Header.Get(HandleHeader) != handle {
150 res.Body.Close()
151 return nil, errors.New("dbdoor: console did not enforce the pinned handle")
152 }
153 return res.Body, nil
154 case http.StatusNotFound:
155 res.Body.Close()
156 return nil, fmt.Errorf("dbdoor: level %d %s: %w", level, Span(uint64(minTXID), uint64(maxTXID)), os.ErrNotExist)
157 default:
158 defer res.Body.Close()
159 return nil, doorError(res)
160 }
161}
162
163func (c *Client) WriteLTXFile(context.Context, int, ltx.TXID, ltx.TXID, io.Reader) (*ltx.FileInfo, error) {
164 return nil, errReadOnly
165}
166
167func (c *Client) DeleteLTXFiles(context.Context, []*ltx.FileInfo) error { return errReadOnly }
168
169func (c *Client) DeleteAll(context.Context) error { return errReadOnly }
170
171// doorError reads a refusal's {"error"} sentence, and the db region
172// forward marker beside it on a 409. A body that is not that
173// shape (the stdlib's 404 text, a proxy's page) keeps only the status.
174func doorError(res *http.Response) error {
175 raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
176 var body struct {
177 Error string `json:"error"`
178 Forward int `json:"forward"`
179 Region string `json:"region"`
180 ForwardOps []string `json:"forward_ops"`
181 }
182 e := &DoorError{Status: res.StatusCode}
183 if json.Unmarshal(raw, &body) == nil {
184 e.Message = strings.TrimSpace(body.Error)
185 if res.StatusCode == http.StatusConflict && body.Forward == 1 && forwardRegion.MatchString(body.Region) {
186 e.Forward = &Forward{Region: body.Region, Ops: body.ForwardOps}
187 }
188 }
189 // Seconds only (the door never sends a date), capped so a hostile or
190 // broken value cannot park a download for hours.
191 if secs, err := strconv.Atoi(strings.TrimSpace(res.Header.Get("Retry-After"))); err == nil && secs > 0 {
192 // Clamp the count before multiplying: a large one would wrap.
193 e.RetryAfter = time.Duration(min(secs, 60)) * time.Second
194 }
195 return e
196}
197
198var _ litestream.ReplicaClient = (*Client)(nil)
199