package dbdoor import ( "context" "errors" "fmt" "io" "log/slog" "time" "github.com/benbjohnson/litestream" "github.com/superfly/ltx" ) // RestorePlan is what a restore will read and produce, known before // anything is downloaded (seeded-instances spec §4 step 5, codex R2-19). type RestorePlan struct { // TXID is the last planned file's MaxTXID: where the restore ends. For // a target it is the target itself; for the latest copy it is the // latest transaction when planned. RestoreToTarget at TXID reproduces // exactly this plan's RestoredBytes, however far the source has moved // on since, as long as that history is still kept. TXID uint64 LTXBytes int64 // the planned files' compressed sizes, summed RestoredBytes int64 // the restored database's size: Commit pages times PageSize Files int // Target is what was planned for: the requested target, or 0 (latest) // when the request is at or past the latest transaction, which // litestream would otherwise refuse as not available. Target uint64 // Planned is the plan's files in restore order (box side: what the // worker is sent, db region forward spec §8.2). Planned []LTXFile // At is the last planned file's header time: the time of the copy's // newest transaction, as RestoreAsOf's Restored.At reports it. At time.Time } // PlanRestore plans a restore to target (0: latest) through c and reads the // header of the last planned file with a ranged open of its first // ltx.HeaderSize bytes. A target older than the retained history, or one // no retained chain of files ends at, is ErrHistoryGone; a replica with // nothing in it is ErrNothingReplicated, with or without a target. func PlanRestore(ctx context.Context, c *Client, target uint64) (RestorePlan, error) { if target != 0 { latest, err := PlanRestore(ctx, c, 0) if err != nil { return RestorePlan{}, err } if target >= latest.TXID { return latest, nil // at or past the end: the latest copy } } infos, err := litestream.CalcRestorePlan(ctx, c, ltx.TXID(target), time.Time{}, slog.Default()) switch { case errors.Is(err, litestream.ErrTxNotAvailable) || errors.Is(err, litestream.ErrNoSnapshots): if target != 0 { return RestorePlan{}, ErrHistoryGone } return RestorePlan{}, ErrNothingReplicated case err != nil: return RestorePlan{}, err case len(infos) == 0: return RestorePlan{}, ErrNothingReplicated } return finishPlan(ctx, c, infos, target) } // finishPlan sums the plan, records its files, and reads and bounds the // last planned file's header: the size check's one decode (db region // forward spec §8.1). PlanRestore and PlanFrom both end here, so both read // and check the last file's header the same way. func finishPlan(ctx context.Context, c litestream.ReplicaClient, infos []*ltx.FileInfo, target uint64) (RestorePlan, error) { p := RestorePlan{Target: target, Files: len(infos)} for _, f := range infos { p.LTXBytes += f.Size p.Planned = append(p.Planned, LTXFile{Level: f.Level, MinTXID: uint64(f.MinTXID), MaxTXID: uint64(f.MaxTXID), Size: f.Size, CreatedAt: f.CreatedAt}) } last := infos[len(infos)-1] p.TXID = uint64(last.MaxTXID) rc, err := c.OpenLTXFile(ctx, last.Level, last.MinTXID, last.MaxTXID, 0, ltx.HeaderSize) if err != nil { return RestorePlan{}, fmt.Errorf("dbdoor: read the last planned file's header: %w", err) } defer rc.Close() buf := make([]byte, ltx.HeaderSize) if _, err := io.ReadFull(rc, buf); err != nil { return RestorePlan{}, fmt.Errorf("dbdoor: read the last planned file's header: %w", err) } var h ltx.Header if err := h.UnmarshalBinary(buf); err != nil { return RestorePlan{}, fmt.Errorf("dbdoor: the last planned file's header: %w", err) } // The header is the replica's word, so untrusted, and UnmarshalBinary // checks only its magic. A page size SQLite allows (512 to 65536) // bounds the product below 2^48, so it cannot wrap; a zero commit is // no database; and TXIDs other than the listed file's mean the header // describes some other file. if !ltx.IsValidPageSize(h.PageSize) || h.Commit == 0 || h.MinTXID != last.MinTXID || h.MaxTXID != last.MaxTXID { return RestorePlan{}, fmt.Errorf("dbdoor: the last planned file's header does not describe it (level %d %s: page size %d, commit %d, txids %s)", last.Level, Span(uint64(last.MinTXID), uint64(last.MaxTXID)), h.PageSize, h.Commit, Span(uint64(h.MinTXID), uint64(h.MaxTXID))) } p.RestoredBytes = int64(h.Commit) * int64(h.PageSize) p.At = time.UnixMilli(h.Timestamp).UTC() return p, nil }