| 1 | package dbdoor |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "log/slog" |
| 9 | "sort" |
| 10 | "sync" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/benbjohnson/litestream" |
| 14 | "github.com/superfly/ltx" |
| 15 | ) |
| 16 | |
| 17 | // ReplicaReader is the read half of a litestream replica client: the two |
| 18 | // methods a door or a planner may hold. A *lss3.ReplicaClient from |
| 19 | // lease.ReadReplicaClient is one; so is a file replica client. |
| 20 | type ReplicaReader interface { |
| 21 | LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) |
| 22 | OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) |
| 23 | } |
| 24 | |
| 25 | // ListLevel lists one level of rd in the door's wire shape, ordered by |
| 26 | // MinTXID, with an empty level as an empty list, and with S3's LastModified |
| 27 | // as CreatedAt (useMetadata false). The console's door and the box's planner |
| 28 | // both list through it, so a forwarded --at stops at the transaction an |
| 29 | // in-region one does (spec §6, T6). |
| 30 | func ListLevel(ctx context.Context, rd ReplicaReader, level int) (ListResponse, error) { |
| 31 | itr, err := rd.LTXFiles(ctx, level, 0, false) |
| 32 | if err != nil { |
| 33 | return ListResponse{}, err |
| 34 | } |
| 35 | out := ListResponse{Files: []LTXFile{}} |
| 36 | for itr.Next() { |
| 37 | f := itr.Item() |
| 38 | out.Files = append(out.Files, LTXFile{ |
| 39 | Level: level, MinTXID: uint64(f.MinTXID), MaxTXID: uint64(f.MaxTXID), |
| 40 | Size: f.Size, CreatedAt: f.CreatedAt, |
| 41 | }) |
| 42 | } |
| 43 | err = itr.Err() |
| 44 | if cerr := itr.Close(); err == nil { |
| 45 | err = cerr |
| 46 | } |
| 47 | if err != nil { |
| 48 | return ListResponse{}, err |
| 49 | } |
| 50 | sort.Slice(out.Files, func(i, j int) bool { return out.Files[i].MinTXID < out.Files[j].MinTXID }) |
| 51 | return out, nil |
| 52 | } |
| 53 | |
| 54 | // listing is a read-only litestream.ReplicaClient over rd that lists through |
| 55 | // ListLevel. For an as-of plan it also does what the CLI's recorder does |
| 56 | // over the door's listings: refuse a listing with no times (ErrNoTimes) and |
| 57 | // remember the earliest file a restore could start from. |
| 58 | type listing struct { |
| 59 | rd ReplicaReader |
| 60 | asOf bool |
| 61 | mu sync.Mutex |
| 62 | oldest time.Time |
| 63 | } |
| 64 | |
| 65 | func (l *listing) Type() string { return "carlos-db-listing" } |
| 66 | func (l *listing) Init(context.Context) error { return nil } |
| 67 | func (l *listing) SetLogger(*slog.Logger) {} |
| 68 | |
| 69 | func (l *listing) LTXFiles(ctx context.Context, level int, seek ltx.TXID, _ bool) (ltx.FileIterator, error) { |
| 70 | out, err := ListLevel(ctx, l.rd, level) |
| 71 | if err != nil { |
| 72 | return nil, err |
| 73 | } |
| 74 | if l.asOf { |
| 75 | l.mu.Lock() |
| 76 | defer l.mu.Unlock() |
| 77 | for _, f := range out.Files { |
| 78 | if f.CreatedAt.IsZero() { |
| 79 | return nil, ErrNoTimes |
| 80 | } |
| 81 | if level == litestream.SnapshotLevel || f.MinTXID == 1 { |
| 82 | if l.oldest.IsZero() || f.CreatedAt.Before(l.oldest) { |
| 83 | l.oldest = f.CreatedAt |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | return fileIterator(out.Files, level, seek), nil |
| 89 | } |
| 90 | |
| 91 | func (l *listing) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) { |
| 92 | return l.rd.OpenLTXFile(ctx, level, minTXID, maxTXID, offset, size) |
| 93 | } |
| 94 | |
| 95 | func (l *listing) WriteLTXFile(context.Context, int, ltx.TXID, ltx.TXID, io.Reader) (*ltx.FileInfo, error) { |
| 96 | return nil, errReadOnly |
| 97 | } |
| 98 | |
| 99 | func (l *listing) DeleteLTXFiles(context.Context, []*ltx.FileInfo) error { return errReadOnly } |
| 100 | |
| 101 | func (l *listing) DeleteAll(context.Context) error { return errReadOnly } |
| 102 | |
| 103 | var _ litestream.ReplicaClient = (*listing)(nil) |
| 104 | |
| 105 | // PlanFrom plans the restore RestoreAsOf would make through the door, from |
| 106 | // rd directly: the newest copy when at is zero, else the copy holding every |
| 107 | // file listed before at rounded down to its second. Its errors are |
| 108 | // RestoreAsOf's: ErrNothingReplicated, a *BeforeHistoryError naming the |
| 109 | // oldest point kept, ErrNoTimes. |
| 110 | func PlanFrom(ctx context.Context, rd ReplicaReader, at time.Time) (RestorePlan, error) { |
| 111 | l := &listing{rd: rd, asOf: !at.IsZero()} |
| 112 | var ts time.Time |
| 113 | if !at.IsZero() { |
| 114 | if at.Truncate(time.Second).Before(EarliestAsOf) { |
| 115 | at = EarliestAsOf |
| 116 | } |
| 117 | ts = at.Truncate(time.Second) |
| 118 | } |
| 119 | infos, err := litestream.CalcRestorePlan(ctx, l, 0, ts, slog.Default()) |
| 120 | switch { |
| 121 | case errors.Is(err, ErrNoTimes): |
| 122 | return RestorePlan{}, ErrNoTimes |
| 123 | case errors.Is(err, litestream.ErrTxNotAvailable) || errors.Is(err, litestream.ErrNoSnapshots): |
| 124 | l.mu.Lock() |
| 125 | oldest := l.oldest |
| 126 | l.mu.Unlock() |
| 127 | if !ts.IsZero() && !oldest.IsZero() { |
| 128 | if !oldest.Before(ts) { |
| 129 | return RestorePlan{}, &BeforeHistoryError{Oldest: oldest.Truncate(time.Second).Add(time.Second)} |
| 130 | } |
| 131 | return RestorePlan{}, err |
| 132 | } |
| 133 | return RestorePlan{}, ErrNothingReplicated |
| 134 | case err != nil: |
| 135 | return RestorePlan{}, err |
| 136 | case len(infos) == 0: |
| 137 | return RestorePlan{}, ErrNothingReplicated |
| 138 | } |
| 139 | return finishPlan(ctx, l, infos, 0) |
| 140 | } |
| 141 | |
| 142 | // RestoreLocal restores the replica c holds, a litestream file replica in a |
| 143 | // private directory (the db-read worker's, or the CLI's for a forwarded |
| 144 | // pull), exactly to target, at out, and proves it with lease.CheckDBContext. |
| 145 | // On failure nothing is left at out. |
| 146 | func RestoreLocal(ctx context.Context, c litestream.ReplicaClient, out string, target uint64) error { |
| 147 | // litestream's compaction checks no context, so every read of the local |
| 148 | // files checks it here, as the door path's downloaded.Read does (Astra |
| 149 | // round 1 on plan 3, finding 2). |
| 150 | if err := restoreOnce(ctx, ctxReplica{ReplicaClient: c, ctx: ctx}, out, target, time.Time{}); err != nil { |
| 151 | removeCopy(out) |
| 152 | if cerr := ctx.Err(); cerr != nil { |
| 153 | return cerr |
| 154 | } |
| 155 | return err |
| 156 | } |
| 157 | if err := checkDB(ctx, out); err != nil { |
| 158 | removeCopy(out) |
| 159 | if cerr := ctx.Err(); cerr != nil { |
| 160 | return cerr |
| 161 | } |
| 162 | return fmt.Errorf("%w: %v", ErrCheckFailed, err) |
| 163 | } |
| 164 | if err := ctx.Err(); err != nil { |
| 165 | removeCopy(out) |
| 166 | return err |
| 167 | } |
| 168 | return nil |
| 169 | } |
| 170 | |
| 171 | // ctxReplica is a local replica client whose file reads stop once ctx is |
| 172 | // done. |
| 173 | type ctxReplica struct { |
| 174 | litestream.ReplicaClient |
| 175 | ctx context.Context |
| 176 | } |
| 177 | |
| 178 | func (c ctxReplica) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) { |
| 179 | if err := c.ctx.Err(); err != nil { |
| 180 | return nil, err |
| 181 | } |
| 182 | rc, err := c.ReplicaClient.OpenLTXFile(ctx, level, minTXID, maxTXID, offset, size) |
| 183 | if err != nil { |
| 184 | return nil, err |
| 185 | } |
| 186 | return ctxReadCloser{ReadCloser: rc, ctx: c.ctx}, nil |
| 187 | } |
| 188 | |
| 189 | type ctxReadCloser struct { |
| 190 | io.ReadCloser |
| 191 | ctx context.Context |
| 192 | } |
| 193 | |
| 194 | func (r ctxReadCloser) Read(p []byte) (int, error) { |
| 195 | if err := r.ctx.Err(); err != nil { |
| 196 | return 0, err |
| 197 | } |
| 198 | return r.ReadCloser.Read(p) |
| 199 | } |
| 200 | |