// Package backup makes restore points of the two things this deployment cannot re-create: the paid // translation work, and the money registry. // // ⚠ WHY IT EXISTS AND WHAT IT IS FOR. A book's translated text lives in the engine's SQLite on the // host's disk; credit, holds and the ledger live in Postgres. Until this package there was no copy // of either anywhere (unified backlog row 269) — one lost disk was every user's paid work AND the // record of who had paid what, with no way to restore it and no way to prove it. That is the one // failure of this deployment with no partial outcome, which is why the answer here is deliberately // unclever: whole files, whole dumps, a manifest that says what each one is, and a restore an // operator performs with `cp` and `pg_restore` rather than with a tool of ours. // // # The shape: a restore point is a DIRECTORY // // /20260905T120000Z/ // manifest.json — version, when, what is inside, sha256 and size of every file // postgres.dump — pg_dump --format=custom of the whole database // books//… — the book's directory, recursively, minus what copyBookFiles skips and // RECORDS as skipped, plus the engine's consistent snapshot of its // project database as `project.db` // // A directory and not an archive, for one reason that outweighs the tidiness of a tarball: a // restore point must be usable by an operator with nothing but a shell on a bad day, and a // half-written archive is unreadable where a half-written directory is merely incomplete. It is // PUBLISHED by rename from `.partial-`, so a restore point that exists is a restore point // that finished. // // # Three decisions this package makes, and why // // 1. **The ENGINE makes the database copy, never this side.** D39.85 forbids the platform to open // the engine's SQLite, and that rule is also the correct engineering: a live SQLite copied // byte-wise while a run writes it is silently torn. `tmctl backup` integrity-checks and // `VACUUM INTO`s (runner/backup.go), so what lands here is a file nobody is writing. // 2. **Postgres has no partial outcome and books do.** A restore point without the money registry // cannot answer "who paid what", so a failed dump DISCARDS the whole point. A book that could // not be copied leaves the point publishable and marked incomplete: nine books saved beat none, // and the manifest says which one is missing rather than implying it is there. // 3. **Integrity is proven twice, in two different senses.** The engine proves the SOURCE is a // sound database (`PRAGMA integrity_check`). This package proves the COPY is the source, by // recording a sha256 it can re-check later without the original (Verify). What it does NOT // claim is that the copy is a sound database — that would need opening it, which this side may // not do. // // # What is deliberately NOT backed up // // Built exports (`TM_PLATFORM_EXPORTS_DIR`) are TTL-ephemeral by design and rebuildable from the // book; run state (`TM_PLATFORM_STATE_DIR`) is exit markers whose whole life is one run. // // ⚠ AND THE ENGINE'S OWN `backups/` SUBDIRECTORY INSIDE A BOOK IS NOT SUPERSEDED BY THIS — an earlier // draft of this comment said it was, and that was wrong in the direction that matters. Those are // PRE-RUN rollback points, taken by the engine's own guard before each paid run // (backend/cmd/tmctl/backup.go, preflightBackup); a restore point here is taken on a SCHEDULE and can // therefore already contain whatever a bad run did. They answer different questions — "undo that run" // versus "the disk is gone" — and only the second is this package's. They are left untouched, and // they are not copied because copying every previous copy on every pass is what makes a backup // directory grow quadratically. // // Each of these is a decision and not an omission — see the runbook section this package's operator // documentation lives in (deploy/README.md, «Бэкап и восстановление»). package backup import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" "log/slog" "net/url" "os" "os/exec" "path/filepath" "sort" "strings" "time" "textmachine/platform/internal/books" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/runner" ) // ManifestVersion is the restore point's document version. // // It carries one for the same reason every document crossing the engine seam does (17-seam-inbound-law // п.3): a reader of a restore point may be a build of this platform written long after the point was // made, and "what shape is this" must be answerable without guessing. It is the platform's own // document, so the version is the platform's own. const ManifestVersion = "tm-platform-restore-point-v1" // ManifestFile, DumpFile, BooksDir and ProjectDBName are the fixed names inside a restore point. // Fixed, because the restore procedure in the runbook names them and an operator types them. const ( ManifestFile = "manifest.json" DumpFile = "postgres.dump" BooksDir = "books" ProjectDBName = "project.db" ) // partialPrefix marks a restore point that is still being written. A leading dot keeps it out of a // plain `ls` and out of the listing this package itself does: a point is only a point once renamed. const partialPrefix = ".partial-" // stampLayout is the restore point's directory name: sortable, filesystem-safe, and the same shape // the engine stamps its own restore points with (backend/cmd/tmctl/backup.go, backupStamp). const stampLayout = "20060102T150405Z" // Store is the storage this needs. An interface so the service's decisions are testable without a // database — the same shape the export door's Store has, and for the same reason. type Store interface { BooksForBackup(ctx context.Context) ([]pgstore.BookDirectory, error) } // Engine is the engine half: `tmctl backup` against a book. type Engine interface { Backup(ctx context.Context, binary, workdir string) (runner.BackupOutcome, error) } // Config is what an operator chooses. type Config struct { // Dir is where restore points live. EMPTY DISABLES BACKUPS ENTIRELY, and that is a loud // default rather than a quiet one: the daemon says so at boot, because a deployment that // believes it has backups and does not is the state this package exists to end. // // Absolute, and the runbook asks for a path on a DIFFERENT device from the books and the // database — this package cannot check that (a bind mount and a symlink both defeat any test // of it), so it is documented as the operator's part of the bargain and named in the boot log. Dir string // Every is how often a restore point is taken. The pass rides the existing sweep ticker and // decides for itself whether it is due, so this is a real interval and not a multiple of the // tick. Every time.Duration // Keep is how many restore points survive. Older ones are removed after a new one is published // — after, never before: a prune that ran first would, on a host that then failed to write the // new point, leave fewer copies than it started with. Keep int // PgDumpBin and PgRestoreBin are the standard PostgreSQL tools. Named rather than assumed on // PATH because a deployment that installs Postgres under a prefix (the development stand does) // has them nowhere the daemon's PATH looks. // // ⚠ Their MAJOR version must match the server's: pg_dump refuses a server newer than itself. PgDumpBin string PgRestoreBin string // DSN is the database to dump. It carries a password, which is why it never reaches an argv — // see dumpArgs. DSN string // EngineBinary is the versioned path of tmctl, the same one the runner and the export door use. // Empty means this instance cannot ask for a consistent database copy, and then books are // skipped and said to be skipped: a restore point that quietly held no book text would be the // worst object this package could produce. EngineBinary string } // Manifest is what a restore point says about itself. type Manifest struct { Version string `json:"version"` Stamp string `json:"stamp"` TakenAt time.Time `json:"taken_at"` // Complete is false when any book could not be copied. The Postgres half has no such flag: a // point without it is never published. Complete bool `json:"complete"` Postgres File `json:"postgres"` Books []BookEntry `json:"books"` // Notes are the decisions a restoring operator has to know and would otherwise have to infer, // carried INSIDE the point rather than only in a runbook that may not survive with it. Notes []string `json:"notes"` } // File is one file inside a restore point. type File struct { // Name is relative to the restore point's own directory. Name string `json:"name"` Bytes int64 `json:"bytes"` SHA256 string `json:"sha256"` } // BookEntry is one book inside a restore point. type BookEntry struct { BookID string `json:"book_id"` // Workdir is where the book lived on the host that made this point — the path a restore puts it // back at, unless the operator is restoring onto a different layout. Workdir string `json:"workdir"` // Files are everything copied out of the book's directory, the consistent project database // among them (ProjectDBName). Files []File `json:"files"` // Skipped says why this book carries no PROJECT DATABASE in the point — NOT that it carries // nothing. A book whose intake has not finished has its source, its `book.yaml` and its journal in // here and no database, because no database exists yet; `Files` is what it does carry. Empty when // the database is present. Skipped string `json:"skipped,omitempty"` // NotCopied names what inside the directory was deliberately left out, with the reason. It is // recorded rather than assumed known: a restore point that quietly omits something is the object // this whole package exists to not be, and a reader six months from now cannot re-derive the // rules from the files that are present. NotCopied []string `json:"not_copied,omitempty"` } // Result is what one Take did, for the caller's log and metrics. type Result struct { Stamp string Path string Books int Skipped int Bytes int64 Complete bool } // Service takes restore points. type Service struct { Cfg Config Store Store Engine Engine Log *slog.Logger // Now is the clock, injectable so a test can place two restore points in one second apart. Now func() time.Time } func (s *Service) log() *slog.Logger { if s.Log == nil { return slog.Default() } return s.Log } func (s *Service) now() time.Time { if s.Now == nil { return time.Now() } return s.Now() } // Enabled reports whether this deployment takes backups at all. func (s *Service) Enabled() bool { return s != nil && s.Cfg.Dir != "" } // Sweep takes a restore point if one is due. It is the daemon's entry point and rides the ordinary // sweep ticker: the interval is decided HERE, against the newest point on disk, so the schedule // survives a restart with no state of its own. func (s *Service) Sweep(ctx context.Context) error { if !s.Enabled() { return nil } points, err := List(s.Cfg.Dir) if err != nil { return err } // ⚠ DUE-NESS IS JUDGED ON THE NEWEST POINT OF ANY KIND, not on the newest COMPLETE one — unlike // Age. The difference is deliberate: a deployment with one permanently uncopyable book would // otherwise find no complete point, decide a point is due, and take a full copy of itself on // every tick forever. The gauge is what must be honest about incompleteness; the schedule must // only not run away. if len(points) > 0 { if newest, perr := time.Parse(stampLayout, points[len(points)-1]); perr == nil { if s.now().Sub(newest) < s.Cfg.Every { return nil } } } _, err = s.Take(ctx) return err } // Take makes one restore point. func (s *Service) Take(ctx context.Context) (Result, error) { if !s.Enabled() { return Result{}, errors.New("backup: no backup directory is configured (TM_PLATFORM_BACKUP_DIR)") } stamp := s.now().UTC().Format(stampLayout) final := filepath.Join(s.Cfg.Dir, stamp) if _, err := os.Stat(final); err == nil { // One restore point per second is the resolution of the stamp, and two in the same second // would silently merge. Refused rather than overwritten: this directory is the only copy of // something. return Result{}, fmt.Errorf("backup: a restore point named %s already exists", stamp) } if err := os.MkdirAll(s.Cfg.Dir, 0o750); err != nil { return Result{}, fmt.Errorf("backup: create the backup directory: %w", err) } staging := filepath.Join(s.Cfg.Dir, partialPrefix+stamp) // ⚠ Mkdir AND NOT MkdirAll, AND THAT IS THE MUTUAL EXCLUSION — measured, not theorised. With // MkdirAll two passes in the same second (the daemon's sweep and an operator's `tmplatformctl // backup`, or two instances) both "created" the same staging directory and then wrote into it // together: the first to finish RENAMED it away, the second's copies fell into a path that no // longer existed, and — far worse — the winner published a point whose only book had been skipped // because the ENGINE refused to overwrite the restore point the loser had just made under the same // timestamp. A point marked `complete` holding zero books is the worst object this package can // produce, and this is one of the two changes that stop it. // // Mkdir is atomic, so exactly one caller wins the second and the other does no work at all. if err := os.Mkdir(staging, 0o750); err != nil { if errors.Is(err, fs.ErrExist) { return Result{}, fmt.Errorf("backup: a restore point for %s is already being written (another pass, or a leftover of a killed one)", stamp) } return Result{}, fmt.Errorf("backup: create staging directory: %w", err) } // Every path out of here that is not a published point removes the staging directory: a leftover // `.partial-` would otherwise accumulate a full copy of the deployment per failure. published := false defer func() { if !published { if err := os.RemoveAll(staging); err != nil { s.log().Error("a partial restore point could not be removed", "path", staging, "err", err) } } }() m := Manifest{Version: ManifestVersion, Stamp: stamp, TakenAt: s.now().UTC(), Complete: true, Notes: restoreNotes()} pg, err := s.dumpPostgres(ctx, staging) if err != nil { // The money registry has no partial outcome (unified backlog row 269): a restore point // without it could not answer who had paid what, so it is not published at all. return Result{}, err } m.Postgres = pg books, err := s.Store.BooksForBackup(ctx) if err != nil { return Result{}, fmt.Errorf("backup: list books: %w", err) } total := pg.Bytes copied, skipped := 0, 0 for _, b := range books { // ⚠ THE DEADLINE ABORTS THE POINT; IT DOES NOT TRUNCATE IT. Without this check the pass would // grind on past its budget: every remaining engine call fails instantly against a cancelled // context, each becomes a "hole", and the point is PUBLISHED anyway — incomplete, but with a // fresh timestamp. Since the books are listed oldest-first, the ones that would be dropped are // the newest — the ones being paid for right now — and every generation would drop them again. // A point that was never taken is an honest state the age gauge reports; a point that silently // omits this week's work is not. if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("backup: the pass ran out of its budget after %d of %d books; no restore point was published: %w", copied+skipped, len(books), err) } entry, err := s.copyBook(ctx, staging, b) if err != nil { // One book's failure is not the deployment's: the point is published, marked incomplete, // and the manifest names the book and the reason. // ⚠ NO BOOK ID. The zone's norm forbids a user's or a book's id in a log line // (ENGINEERING_STANDARDS §«структурные логи»), and acceptance has caught this exact class // here before (internal/httpapi/stream.go, "NO BOOK ID"). Nothing is lost by obeying it: // the MANIFEST names the book and the reason, which is the durable record an operator // actually reads, and this line's job is only to make the failure visible while it happens. s.log().ErrorContext(ctx, "a book could not be copied into the restore point; the manifest names which one", "err", err) // ⚠ AND WHAT IT HAD ALREADY COPIED IS REMOVED, which is the half that matters. A book that // failed PART WAY leaves real files in the staging directory — very possibly a truncated // `project.db` — and the entry that replaces it says "skipped" and lists none of them. They // would then be published, unhashed and unmentioned, and the runbook's restore step is a // plain `cp -a books//. /`: the operator would copy a torn database back over // a good one while the manifest told them this book was not in the point at all. // // A skipped book leaves NOTHING. The manifest then describes the directory exactly. if rmErr := os.RemoveAll(filepath.Join(staging, BooksDir, b.ID)); rmErr != nil { s.log().ErrorContext(ctx, "a partly copied book could not be removed from the restore point", "err", rmErr) } entry = BookEntry{BookID: b.ID, Workdir: b.Workdir, Skipped: err.Error()} m.Complete = false } if entry.Skipped != "" { skipped++ } else { copied++ } for _, f := range entry.Files { total += f.Bytes } m.Books = append(m.Books, entry) } if err := writeManifest(staging, m); err != nil { return Result{}, err } // The point's contents are on the disk before its name is — every file is Sync'd as it is written, // and so is the manifest; this flushes the staging directory's own entries, so the rename below // moves a directory that is whole. if err := syncDir(staging); err != nil { s.log().Warn("the restore point's directory could not be flushed before publishing", "err", err) } if err := os.Rename(staging, final); err != nil { return Result{}, fmt.Errorf("backup: publish restore point: %w", err) } published = true if err := syncDir(s.Cfg.Dir); err != nil { s.log().Warn("the backup directory could not be flushed after publishing", "err", err) } // Pruning is AFTER the publish and its failure is not the backup's: a restore point that exists // is worth more than a tidy directory, and the next pass tries again. if err := s.prune(); err != nil { s.log().Error("old restore points could not be pruned", "err", err) } res := Result{Stamp: stamp, Path: final, Books: copied, Skipped: skipped, Bytes: total, Complete: m.Complete} // Counts and paths, never a money figure and never a book title (ENGINEERING_STANDARDS // §Наблюдаемость). s.log().InfoContext(ctx, "a restore point was written", "stamp", stamp, "books", copied, "skipped", skipped, "bytes", total, "complete", m.Complete) return res, nil } // restoreNotes is what a restoring operator must know and cannot see from the files. // // It lives INSIDE the point because a restore happens on the worst day this deployment has, possibly // from a copy that travelled without the repository the runbook is in. func restoreNotes() []string { return []string{ "Restore Postgres with: pg_restore --clean --if-exists --no-owner -d " + DumpFile, "Restore a book by copying " + BooksDir + "// back to its workdir, then renaming " + ProjectDBName + " to the file that book's book.yaml names in `project_db` — absent that key, .db.", "A book directory with NO " + ProjectDBName + " is not an error: that book's intake had not finished, so no " + "project database existed yet. Restore its files as they are.", "The engine's own pre-run restore points (a book's backups/ subdirectory) are NOT in here and are NOT superseded by these: " + "they are a different guarantee (a rollback point taken BEFORE a paid run, on the same disk) and are left untouched.", "Built exports and run-state markers are NOT in here: both are rebuildable and short-lived by design.", } } // dumpPostgres writes the money registry. func (s *Service) dumpPostgres(ctx context.Context, staging string) (File, error) { out := filepath.Join(staging, DumpFile) args, env, err := dumpArgs(s.Cfg.DSN, out) if err != nil { return File{}, err } cmd := exec.CommandContext(ctx, s.Cfg.PgDumpBin, args...) cmd.Env = env var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return File{}, fmt.Errorf("backup: pg_dump: %w: %s", err, firstLine(stderr.String())) } // The archive's TABLE OF CONTENTS is parsed before the point is published, which catches the // failure that actually happens: a truncated or empty file, a tool that wrote nothing, a version // mismatch that produced a header and stopped. // // ⚠ WHAT IT DOES NOT PROVE, said plainly because an earlier edition of this comment claimed more: // `pg_restore --list` reads the TOC and not the data blocks, so a green answer here means "this is // a readable archive", never "this restores". Only a restore proves a restore, and that is why one // is EXECUTED in the battery (backup_live_test.go) rather than argued for here. if s.Cfg.PgRestoreBin != "" { check := exec.CommandContext(ctx, s.Cfg.PgRestoreBin, "--list", out) var checkErr strings.Builder check.Stderr = &checkErr check.Stdout = io.Discard if err := check.Run(); err != nil { return File{}, fmt.Errorf("backup: the dump this pass wrote cannot be read back by pg_restore --list: %w: %s", err, firstLine(checkErr.String())) } } return describe(staging, out) } // dumpArgs builds pg_dump's argv and environment from the DSN. // // ⚠ THE PASSWORD NEVER REACHES AN ARGV. A connection string on the command line is readable by // every process on the host through /proc//cmdline, and the DSN carries a password // (config.Config.DSN). It travels in the child's environment instead, which is the channel // PostgreSQL's own tools document for it (PGPASSWORD) and which is scoped to the child. func dumpArgs(dsn, out string) (args []string, env []string, err error) { // ⚠ THE WHOLE CONNECTION STRING IS FORWARDED, MINUS THE PASSWORD — it is not rebuilt from parts. // An earlier version passed host/port/user/dbname as four flags and dropped everything else, // which silently included `sslmode`: a deployment whose DSN says `verify-full` would have had its // nightly dump go out under libpq's default `prefer`, i.e. TLS optional and the certificate never // checked. Nothing would have said so — pg_dump succeeds either way. The same loss applies to // `sslrootcert`, `connect_timeout`, `application_name` and anything else an operator put there. // // The password is the one part that may not travel: a connection string on the command line is // readable by every process on the host through /proc//cmdline. It goes in the child's // environment instead, which is the channel PostgreSQL's own tools document for it. u, perr := url.Parse(dsn) if perr != nil || u.Scheme == "" { // A key/value DSN (`host=… port=…`) is legal for libpq and cannot be edited as a URL. Refused // rather than mangled: the alternative is a dump taken under settings nobody chose. return nil, nil, fmt.Errorf("backup: the configured DSN is not a URL this platform can hand to %s without rewriting it; use the postgres:// form", "pg_dump") } password, _ := u.User.Password() if u.User != nil { u.User = url.User(u.User.Username()) } args = []string{ "--format=custom", "--file=" + out, // No prompt, ever: this runs unattended, and a tool that stops for a password is a pass that // hangs until its budget kills it. "--no-password", "--dbname=" + u.String(), } env = append(os.Environ(), "PGPASSWORD="+password) return args, env, nil } // copyBook puts one book's directory into the restore point, with a consistent database. func (s *Service) copyBook(ctx context.Context, staging string, b pgstore.BookDirectory) (BookEntry, error) { entry := BookEntry{BookID: b.ID, Workdir: b.Workdir} if b.Workdir == "" { entry.Skipped = "the book has no directory on this host" return entry, nil } if _, err := os.Stat(b.Workdir); errors.Is(err, fs.ErrNotExist) { // ⚠ A MISSING DIRECTORY IS TWO DIFFERENT FACTS, told apart by the book's own status. // // A REJECTED intake may take its directory with it, so a row outliving one is ordinary. Any // other status means the directory should be there and is not: a books volume that did not // mount, or a tree that moved while the absolute workdirs in Postgres stayed where they were. // Calling that an ordinary skip is how a deployment comes to publish points holding only the // ledger, all marked complete, while the age gauge stays green. if b.Status == statusRejected { entry.Skipped = "the book's intake was rejected and its directory is gone" return entry, nil } return entry, fmt.Errorf("the book's directory %s is not there, and its status (%s) says it should be", b.Workdir, b.Status) } else if err != nil { return entry, fmt.Errorf("stat the book's directory: %w", err) } dest := filepath.Join(staging, BooksDir, b.ID) if err := os.MkdirAll(dest, 0o750); err != nil { return entry, fmt.Errorf("create the book's directory in the restore point: %w", err) } // ⚠ THE DIRECTORY'S FILES ARE COPIED FIRST AND UNCONDITIONALLY, BEFORE ANY QUESTION ABOUT THE // DATABASE. An earlier version answered "this book has no project database yet" by returning // immediately, and so took NOTHING — not the uploaded source, not `book.yaml`, not the journal — // while the point still called itself complete. // // That was wrong on this package's own terms: the source is the one file in the directory nobody // can be asked to supply again, and a book can sit in `parsing` indefinitely rather than for a // moment — the intake's `not_configured` and `storage_unavailable` never become terminal // (internal/books/parse.go), so a host with a broken book template holds every upload in that // state. Those uploads are precisely the ones with no other copy anywhere. files, err := s.copyBookFiles(staging, b.Workdir, dest, &entry.NotCopied) if err != nil { return entry, err } entry.Files = files if s.Cfg.EngineBinary == "" { // NOT a skip: an instance with no engine cannot copy ANY book's database, so every book would // be "skipped" and the point would claim to be a backup of a library it never opened. return entry, errors.New("this instance has no engine binary, so no consistent database copy can be asked for") } res, err := s.Engine.Backup(ctx, s.Cfg.EngineBinary, b.Workdir) if err != nil { return entry, err } if !res.Exited || res.ExitCode != 0 { // ⚠ THE ENGINE'S REFUSAL IS TWO DIFFERENT FACTS AND THEY MUST NOT BE ONE SKIP. // // A book whose intake has not finished — or was rejected — has no project database at all, and // the engine says so rather than failing: an ordinary state that leaves the point COMPLETE, // because the book's FILES are already in it and there is no paid work to lose. Every other // refusal is a book that HAS paid work which this point does not contain, and calling that // "skipped" the same way is how a point comes to say `complete: true` over a hole. // // The discriminator is the PLATFORM's own fact — the book's intake status — and never the // engine's exit code or its prose: this side cannot tell "no database yet" from "a broken // deployment" by a number, and reading it out of a message would be the same unversioned // coupling PD-449 already carries once. if notYetCut(b.Status) || b.Status == statusRejected { entry.Skipped = fmt.Sprintf("no project database yet (status %s), but the book's own files ARE in this point: %s", b.Status, res.Stderr) return entry, nil } return entry, fmt.Errorf("the engine made no copy of a book that should have one (exit %d): %s", res.ExitCode, res.Stderr) } // The consistent snapshot, under the fixed name the manifest's notes tell an operator to rename // back. The engine's copy is then removed: it was made for this pass, and leaving it would grow // the book's own directory by a whole database every cycle. f, err := copyInto(staging, res.Path, filepath.Join(dest, ProjectDBName)) if err != nil { return entry, err } entry.Files = append(entry.Files, f) if err := os.Remove(res.Path); err != nil { s.log().Warn("the engine's copy could not be removed after it was taken into the restore point", "path", res.Path, "err", err) } return entry, nil } // copyBookFiles copies the rest of a book's directory, recursively. // // ⚠ RECURSIVELY, because a book's own data can legitimately live in a subdirectory: `langpack_extend` // is a per-book overlay of the pair's canon and `glossary_seed` may sit under one, both resolved // against `book.yaml` (backend/internal/config/book.go). An earlier version of this function skipped // every subdirectory and still published the point as COMPLETE — a book's canon lost in silence. // // What it skips, each for its own reason: // // - **The live database and SQLite's sidecars of it** (`.db`, `-wal`, `-shm`, `-journal`). The // engine's consistent snapshot is already in as `project.db`; the live file is the torn one. // ⚠ Recognised by SUFFIX and not by name, deliberately the weak direction: the platform must not // derive the engine's project-database path (17-seam-inbound-law п.1), so this is a rule about // what NOT to copy. Its failure mode is copying a file it did not have to — a book whose // `project_db` is spelled without that suffix gets its live file copied BESIDE the snapshot, // which is untidy and never lossy (register row PD-453). // - **The engine's own `backups/`** — its pre-run restore points, which this point neither // supersedes nor replaces (see the package comment); copying every previous copy on every pass // is what makes a backup directory grow quadratically. // - **Dotfiles.** ⚠ THIS ONE IS A SECRET, NOT A TIDINESS RULE: the engine's convention is a `.env` // beside `book.yaml` carrying PROVIDER API KEYS (backend/cmd/tmctl/main.go, `loadDotEnv`), and // the runbook tells the operator to ship this directory OFF THE HOST. A backup that carried the // deployment's keys to wherever backups are shipped is a credential leak with a scheduler. Every // skipped name is recorded in the manifest, so nothing goes missing quietly. // - **Anything that is not a regular file** — following a symlink would copy from outside the book. func (s *Service) copyBookFiles(staging, workdir, dest string, skipped *[]string) ([]File, error) { entries, err := os.ReadDir(workdir) if err != nil { return nil, fmt.Errorf("read the book's directory: %w", err) } var out []File for _, e := range entries { name := e.Name() switch { case strings.HasPrefix(name, "."): *skipped = append(*skipped, name+" (dotfile: may be the engine's .env of provider keys)") continue case e.IsDir(): if name == engineBackupsDir { *skipped = append(*skipped, name+"/ (the engine's own pre-run restore points)") continue } sub, err := s.copyBookFiles(staging, filepath.Join(workdir, name), filepath.Join(dest, name), skipped) if err != nil { return nil, err } out = append(out, sub...) continue case isLiveDatabase(name) && !isTheUploadedSource(name): *skipped = append(*skipped, name+" (the live database or a SQLite sidecar of it)") continue case !e.Type().IsRegular(): *skipped = append(*skipped, name+" (not a regular file)") s.log().Warn("a book's directory holds something that is not a regular file; it is not backed up", "name", name) continue } f, err := copyInto(staging, filepath.Join(workdir, name), filepath.Join(dest, name)) if err != nil { return nil, err } out = append(out, f) } return out, nil } // engineBackupsDir is the engine's own restore-point directory inside a book // (backend/cmd/tmctl/backup.go, backupDirFor). const engineBackupsDir = "backups" // notYetCut reports whether a book's intake has not reached the point where the engine creates its // project database. // // `uploading` is a file still arriving and `parsing` is one the engine has not been asked about yet; // everything after that has been through `tmctl manifest`, which creates and migrates the database // (backend/cmd/tmctl/main.go, manifestCmd: "it opens the store exactly as `status` does — EXCEPT on // the first touch of a project, where that path falls back to a full Open and therefore CREATES and // migrates the database"). A `rejected` book is not listed here because its directory goes with its // source, and the copier answers that case earlier and by looking. func notYetCut(status string) bool { return status == "uploading" || status == "parsing" } // statusRejected is the one intake outcome that legitimately leaves a book row with no directory // (internal/books, the reject path removes the directory with the source). const statusRejected = "rejected" // liveDatabaseSuffixes are the live SQLite file and the two SQLite writes beside it. `.db-journal` // is the rollback-journal name a non-WAL database uses. var liveDatabaseSuffixes = []string{".db", ".db-wal", ".db-shm", ".db-journal"} // isTheUploadedSource reports the one file the suffix rule above must never catch. // // ⚠ THIS IS NOT BELT-AND-BRACES, IT IS A MEASURED HOLE. The intake writes the upload as // `source.` and keeps that extension verbatim — `extensionOf` is explicitly // NOT an allowlist, because which formats exist is the engine's question (internal/books/books.go). // So a reader who uploads `novel.db` gets `/source.db`, and a rule that skips everything // ending in `.db` would drop THE BOOK'S OWN TEXT from every restore point — silently, and in exactly // the direction the rule's comment claims is impossible ("its failure mode is copying a file it did // not have to"). The source is the one thing in that directory the user cannot be asked to supply // again. // // It is recognised by the name the INTAKE gives it, which is this platform's own fact, not the // engine's convention — so nothing here derives a path across the seam. func isTheUploadedSource(name string) bool { return strings.HasPrefix(name, books.SourceName+".") } func isLiveDatabase(name string) bool { for _, s := range liveDatabaseSuffixes { if strings.HasSuffix(name, s) { return true } } return false } // copyInto copies one file and describes it. The copy is read back through the hash as it is // written, so the digest in the manifest is of the bytes that landed rather than of the bytes that // were read. func copyInto(root, src, dst string) (File, error) { in, err := os.Open(src) if err != nil { return File{}, fmt.Errorf("open %s: %w", filepath.Base(src), err) } defer in.Close() if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { return File{}, fmt.Errorf("create %s: %w", filepath.Dir(dst), err) } out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) if err != nil { return File{}, fmt.Errorf("create %s: %w", filepath.Base(dst), err) } defer out.Close() if _, err := io.Copy(out, in); err != nil { return File{}, fmt.Errorf("copy %s: %w", filepath.Base(src), err) } // The data is on the disk before the manifest claims it is. Without this a host that lost power // between the rename and the flush would publish a restore point of empty files. if err := out.Sync(); err != nil { return File{}, fmt.Errorf("flush %s: %w", filepath.Base(dst), err) } if err := out.Close(); err != nil { return File{}, fmt.Errorf("close %s: %w", filepath.Base(dst), err) } return describe(root, dst) } // describe measures a file inside a restore point. func describe(root, path string) (File, error) { rel, err := filepath.Rel(root, path) if err != nil { return File{}, fmt.Errorf("backup: name %s inside the restore point: %w", path, err) } sum, size, err := hashFile(path) if err != nil { return File{}, err } return File{Name: filepath.ToSlash(rel), Bytes: size, SHA256: sum}, nil } func hashFile(path string) (string, int64, error) { f, err := os.Open(path) if err != nil { return "", 0, fmt.Errorf("backup: open %s: %w", path, err) } defer f.Close() h := sha256.New() n, err := io.Copy(h, f) if err != nil { return "", 0, fmt.Errorf("backup: read %s: %w", path, err) } return hex.EncodeToString(h.Sum(nil)), n, nil } func writeManifest(dir string, m Manifest) error { raw, err := json.MarshalIndent(m, "", " ") if err != nil { return fmt.Errorf("backup: render the manifest: %w", err) } path := filepath.Join(dir, ManifestFile) f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640) if err != nil { return fmt.Errorf("backup: write the manifest: %w", err) } defer f.Close() if _, err := f.Write(append(raw, '\n')); err != nil { return fmt.Errorf("backup: write the manifest: %w", err) } // Synced like every file it describes. Without it a host that lost power just after the publish // would leave a directory that List reads as a restore point and whose manifest is empty — the // schedule would call that a point and the next one would be an interval away. if err := f.Sync(); err != nil { return fmt.Errorf("backup: flush the manifest: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("backup: write the manifest: %w", err) } return nil } // syncDir flushes a directory's own entries, which is what makes a rename survive a power loss — // fsync on a file says nothing about the directory that names it. The engine does the same where it // publishes by rename (backend/internal/pipeline/bankdecisions.go, syncDir). // // Best effort by design: a filesystem that refuses to open a directory this way must not turn a // written restore point into a failed one. func syncDir(path string) error { d, err := os.Open(path) if err != nil { return err } defer d.Close() return d.Sync() } // ReadManifest reads a restore point's manifest. func ReadManifest(point string) (Manifest, error) { raw, err := os.ReadFile(filepath.Join(point, ManifestFile)) if err != nil { return Manifest{}, fmt.Errorf("backup: read the manifest of %s: %w", point, err) } var m Manifest if err := json.Unmarshal(raw, &m); err != nil { return Manifest{}, fmt.Errorf("backup: the manifest of %s does not parse: %w", point, err) } if m.Version != ManifestVersion { // Loud, and this is the reason the document carries a version at all: a shape this build does // not know is not something to read half of. return Manifest{}, fmt.Errorf("backup: the restore point %s is version %q and this build reads %q", point, m.Version, ManifestVersion) } return m, nil } // List returns the stamps of the published restore points in dir, oldest first. func List(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if errors.Is(err, fs.ErrNotExist) { return nil, nil } if err != nil { return nil, fmt.Errorf("backup: read the backup directory: %w", err) } var out []string for _, e := range entries { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } if _, err := time.Parse(stampLayout, e.Name()); err != nil { continue } out = append(out, e.Name()) } sort.Strings(out) // the stamp is lexicographically ordered by construction return out, nil } // Verify re-reads a restore point and checks every file against the digest its manifest recorded. // // This is what makes the manifest more than a description: a copy that rotted on the disk, or one // truncated by a full filesystem, is indistinguishable from a good one by size alone. It answers the // list of what is wrong rather than the first thing, because an operator deciding which restore // point to use needs to compare them. func Verify(point string) ([]string, error) { m, err := ReadManifest(point) if err != nil { return nil, err } var problems []string check := func(f File) { sum, size, err := hashFile(filepath.Join(point, filepath.FromSlash(f.Name))) switch { case err != nil: problems = append(problems, fmt.Sprintf("%s: %v", f.Name, err)) case size != f.Bytes: problems = append(problems, fmt.Sprintf("%s: %d bytes on disk, %d in the manifest", f.Name, size, f.Bytes)) case sum != f.SHA256: problems = append(problems, fmt.Sprintf("%s: sha256 %s on disk, %s in the manifest", f.Name, sum, f.SHA256)) } } check(m.Postgres) for _, b := range m.Books { for _, f := range b.Files { check(f) } } if !m.Complete { problems = append(problems, "the manifest says this restore point is INCOMPLETE: at least one book was not copied") } return problems, nil } // prune removes the oldest restore points beyond Keep, and any staging directory old enough to be a // leftover rather than a pass in flight. func (s *Service) prune() error { points, err := List(s.Cfg.Dir) if err != nil { return err } var errs []error if s.Cfg.Keep > 0 && len(points) > s.Cfg.Keep { // ⚠ THE NEWEST COMPLETE POINT IS NEVER EVICTED, however old it is, and that exception is the // whole difference between a retention policy and a countdown to data loss. Retention counts // DIRECTORIES; a deployment producing degraded points — an unmounted books volume, an engine // that stopped answering — produces them on the same schedule as good ones, so with the // defaults every point that actually holds the paid work is evicted in `Keep × Every` and the // operator's only signal is a gauge that went to +Inf some days after the last real backup // was already deleted. // // Keeping it costs one directory beyond the configured count, and only in the case where that // directory is the last copy of the deployment. keep := newestComplete(s.Cfg.Dir, points) for _, stamp := range points[:len(points)-s.Cfg.Keep] { if stamp == keep { s.log().Warn("the oldest restore point is kept beyond the retention count because it is the newest COMPLETE one; this deployment has been producing incomplete points", "stamp", stamp) continue } if err := os.RemoveAll(filepath.Join(s.Cfg.Dir, stamp)); err != nil { errs = append(errs, err) } } } // A `.partial-` older than a day is a pass that was killed — by a reboot, or by a deadline that // outran the defer. A day, so a pass genuinely still running on a slow host is never removed // under itself. entries, err := os.ReadDir(s.Cfg.Dir) if err != nil { return errors.Join(append(errs, err)...) } for _, e := range entries { if !e.IsDir() || !strings.HasPrefix(e.Name(), partialPrefix) { continue } stamp, perr := time.Parse(stampLayout, strings.TrimPrefix(e.Name(), partialPrefix)) if perr != nil || s.now().Sub(stamp) < 24*time.Hour { continue } if err := os.RemoveAll(filepath.Join(s.Cfg.Dir, e.Name())); err != nil { errs = append(errs, err) } } return errors.Join(errs...) } // newestComplete answers the stamp of the newest point whose manifest says it is complete, or "" if // there is none. It is what prune must not delete. func newestComplete(dir string, points []string) string { for i := len(points) - 1; i >= 0; i-- { m, err := ReadManifest(filepath.Join(dir, points[i])) if err == nil && m.Complete { return points[i] } } return "" } // Age answers how old the newest COMPLETE restore point is, and whether there is one at all. It is // what the daemon publishes as a gauge: "when did this deployment last have a copy of itself" is the // only backup question worth alerting on. // // ⚠ COMPLETE, and the word is the whole value of the gauge. A point that could not copy a book is // published — nine books saved beat none — but it is not a backup OF THAT BOOK, and taking its // timestamp as the age would reset the one number an operator watches while the same book went // uncopied in every generation. A deployment whose points are all incomplete reads `+Inf` here, // which is exactly what it is: no complete copy exists. // // The manifests are read newest-first and the walk stops at the first complete one, so the ordinary // case costs one file read. func (s *Service) Age(now time.Time) (time.Duration, bool, error) { points, err := List(s.Cfg.Dir) if err != nil { return 0, false, err } for i := len(points) - 1; i >= 0; i-- { m, err := ReadManifest(filepath.Join(s.Cfg.Dir, points[i])) if err != nil || !m.Complete { // A point whose manifest cannot be read is not one this deployment can rely on either. continue } taken, err := time.Parse(stampLayout, points[i]) if err != nil { continue } return now.Sub(taken), true, nil } return 0, false, nil } // firstLine keeps a child's diagnostic to one line for a log. func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[:i] } return strings.TrimSpace(s) }