textmachine/platform/internal/exports/exports.go

618 lines
32 KiB
Go

// Package exports owns the export door: turning a book that has been cut into chapters into a file
// a reader can download, and taking that file away again when its link lapses.
//
// It is its own package and not a corner of `runs` because it is not a run: nothing here spends
// money, nothing here holds credit, and the one engine command it uses is $0 and key-less. What it
// shares with `runs` is only the seam's shape — a verb spawned as a child, an exit code read as
// data, a report read through an allowlist.
//
// ⚠ THE DOOR ALWAYS BUILDS (D39.178 п.1, owner 30.08). A book with a hole comes back WITH the
// notice on its first page and a mark at every hole; the engine's refusal-by-default stays an
// operator's handle on the CLI.
//
// ⚠ AND IT NEVER REFUSES BY A BOOK'S STATE (ratified 04.09, canon §createExport: «Nothing about a
// book's state conflicts with exporting it»). Every request is ACCEPTED — a book still being
// translated, a book nobody has cut yet, a book that was rejected. What a book with no text can be
// given is not a file (see Build), but it is given as this export's own OUTCOME, on the resource the
// canon provides for it, and never as a refusal of the request.
//
// ⚠ IT NEVER SERVES THE ENGINE'S OWN COPY. `tmctl build` without `--out` writes beside the project
// database and deletes the formats it was not asked for; that set is the operator's last CLI build
// and presenting it as this request's answer is what D39.175 п.2 forbids. Every export here is
// built to a path of this platform's choosing, is immutable once ready, and is this platform's to
// expire.
package exports
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"time"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/runner"
)
// Store is the storage this needs. An interface so the service's decisions can be pinned without a
// database, and so a test that DOES want Postgres is visibly a different test.
type Store interface {
ReadBookForRun(ctx context.Context, userID, bookID string) (pgstore.BookRunContext, error)
CreateExport(ctx context.Context, bookID, format string, now time.Time,
enqueue func(context.Context, pgstore.Tx, string) error) (pgstore.Export, error)
ReadExport(ctx context.Context, userID, bookID, exportID string) (pgstore.Export, error)
ReadExportForBuild(ctx context.Context, exportID string) (pgstore.ExportBuild, error)
FinishExport(ctx context.Context, exportID, path string, size int64, complete bool, now, expiresAt time.Time) error
FailExport(ctx context.Context, exportID, failureCode string, now time.Time) error
ClaimExportBuild(ctx context.Context, exportID string, now time.Time) error
ExpireExports(ctx context.Context, now time.Time) (int, error)
Unlinked(ctx context.Context) ([]pgstore.Artifact, error)
ForgetExportPath(ctx context.Context, exportID string) error
FailStaleExports(ctx context.Context, failureCode string, startedCutoff, queuedCutoff, now time.Time) ([]string, error)
}
// Builder is the engine half: `tmctl build` against a book.
type Builder interface {
Build(ctx context.Context, binary, workdir, format, out string) (runner.BuildOutcome, error)
}
// Enqueuer hands a build to the queue, inside the transaction that created its row.
type Enqueuer interface {
EnqueueExport(ctx context.Context, tx pgstore.Tx, exportID string) error
}
// Config is what an operator chooses.
type Config struct {
// EngineBinary is the versioned path of tmctl — the same one the runner uses.
EngineBinary string
// Formats are the formats THIS DEPLOYMENT declares its engine can write, in the order
// `GET /capabilities` announces them.
//
// ⚠ It is a DEPLOYMENT DECLARATION and not a discovery, and the reason is the seam's law: the
// engine's own list is a Go variable (`bookfile.Formats`) inside a module this one may never
// import (D39.85), and the engine publishes no `$0` command that would answer the question as
// DATA. The same shape as `TM_PLATFORM_LANGUAGE_PAIRS`, which is a declaration for the same
// reason. The durable cure is the engine publishing its formats as data — the proposal already
// standing for flag reasons (unified backlog row 204) — and until it does, a format declared
// here that this engine does not know comes back as a FAILED export naming the operator's own
// configuration, never as a broken promise a user cannot read.
Formats []string
// Dir is where built artifacts live. Platform state, deliberately NOT the book's directory: that
// one is the engine's (D39.110) and a file this platform expires out from under it would be a
// write into somebody else's tree.
Dir string
// TTL is how long a built artifact and its link live. It bounds disk rather than access: the
// link is authenticated on every request, so what expiry buys is that a book's text does not sit
// on a control-plane disk forever after the one download it was built for.
TTL time.Duration
// StaleAfter is how long a build a worker HAS PICKED UP may be silent before the sweep calls it
// lost. It must OUTLIVE one whole job, or the sweep fails builds that are merely slow.
StaleAfter time.Duration
// QueuedGrace is the same question for a build still WAITING ITS TURN, and it is a different and
// much larger number on purpose. One queue serves spawns, parses and builds; a handful of parses
// ahead of an export is a wait, not a fault, and burying it on the started-build clock would tell
// a user their export was interrupted before it began. The poll still ends — that is what the
// canon requires — it just ends after a wait long enough to be an operator's problem rather than
// a lie.
QueuedGrace time.Duration
}
// Service is the export door.
type Service struct {
Store Store
Engine Builder
Queue Enqueuer
Cfg Config
Log *slog.Logger
NowFunc func() time.Time
}
func (s *Service) now() time.Time {
if s.NowFunc != nil {
return s.NowFunc()
}
return time.Now()
}
func (s *Service) log() *slog.Logger {
if s.Log != nil {
return s.Log
}
return slog.Default()
}
// ErrUnknownFormat is a format outside what this deployment declares. The canon's word for it is
// `400`, because the legal set is published on `GET /capabilities` and a client that asked for
// something else asked for something that never existed here.
var ErrUnknownFormat = errors.New("exports: this deployment builds no such format")
// ErrNotReady is a download of an export that is not `ready`. Told apart from ErrExpired because
// the remedies differ: this one clears by polling, that one by building again.
var ErrNotReady = errors.New("exports: the export is not ready")
// ErrExpired is a download of an artifact whose link has lapsed.
var ErrExpired = errors.New("exports: the export has expired")
// The machine reasons an export can fail with. They are the `Export.failure_code` vocabulary this
// deployment produces, and the canon's own note says the field «becomes an enum with the first
// built format» — this is that set, carried to the orchestrator for ratification rather than
// written into the canon here.
const (
// FailureBookEmpty — the engine found no output units to write. The book was cut, but there is
// no text in it: the engine's `source_unreadable` class reaching `build` (bookbuild.go,
// `the book has no output units`).
// ⚠ It is also what a book NOBODY HAS CUT YET gets, and that is a ratified decision rather than a
// convenience (04.09): the door may not refuse by a book's state, so «there is no text to write»
// travels as this export's outcome instead of as a `409`. One code for both, because the user's
// fact is one — this book has nothing in it to make a file out of — and the remedy is one too:
// wait for the intake, or send a source that has a book in it.
FailureBookEmpty = "book_empty"
// FailureDeploymentError — the engine refused this deployment's own configuration: a format it
// does not build, a `book.yaml` without a language tag, a reader-words file it cannot read, a
// directory it may not write. The operator's files are what need fixing; the user can do nothing
// and is told a machine reason rather than a lie.
//
// ⚠ It is NOT named after the format, and the earlier name (`format_unavailable`) was an
// adversarial pass's finding: exit 10 covers every one of the causes above, so a client phrase
// saying «that format is unavailable» would be false for most of them — and would make a
// deployment's broken langpack look like a format nobody built.
FailureDeploymentError = "deployment_error"
// FailureBuildInterrupted — the build did not finish and nobody is coming back for it: the
// worker's process is gone, or the verb was wound down. Building again converges.
FailureBuildInterrupted = "build_interrupted"
// FailureBuildFailed — the engine could not produce the file and the class is one this build has
// no better word for. The operator has the log line; the user has an answer that ENDS the poll,
// which is the one thing this resource must never fail to give.
FailureBuildFailed = "build_failed"
)
// formatPattern is what a declared format may look like. It is also a path safety rule: the format
// becomes the artifact's extension, and a value carrying a separator or a dot-dot would name a file
// outside the export directory.
var formatPattern = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
// ValidateFormats checks an operator's declaration at BOOT, where a typo is one line in a log
// instead of one user's failed download hours later.
func ValidateFormats(formats []string) error {
seen := map[string]bool{}
for _, f := range formats {
if !formatPattern.MatchString(f) {
return fmt.Errorf("exports: %q is not a usable format name (lower-case letters and digits, at most 16)", f)
}
if seen[f] {
return fmt.Errorf("exports: format %q is declared twice", f)
}
seen[f] = true
}
return nil
}
// Knows reports whether this deployment declares a format.
func (s *Service) Knows(format string) bool {
for _, f := range s.Cfg.Formats {
if f == format {
return true
}
}
return false
}
// Create accepts one export request and hands the build to the queue.
//
// It takes NO book lock, and that is the canon executed rather than an omission: «a book still
// being translated may be exported». The verb opens the project read-only and without the engine's
// exclusive flock, so an export during a live run costs CPU and disturbs nothing.
func (s *Service) Create(ctx context.Context, userID, bookID, format string) (pgstore.Export, error) {
if !s.Knows(format) {
return pgstore.Export{}, ErrUnknownFormat
}
// The book is read for OWNERSHIP and for nothing else. ⚠ It used to be read for its state too, and
// a book with no chapter tree was refused `409 book_not_ready` — which the canon forbids in so
// many words, and which was this code's invention rather than the canon's ambiguity (ratified
// 04.09). What such a book gets is decided where the work happens, not at the door.
if _, err := s.Store.ReadBookForRun(ctx, userID, bookID); err != nil {
return pgstore.Export{}, err
}
return s.Store.CreateExport(ctx, bookID, format, s.now(), s.Queue.EnqueueExport)
}
// Read is the poll. Ownership is checked inside the statement, so an export of somebody else's book
// is indistinguishable from one that does not exist.
//
// ⚠ The lapse is judged HERE too and not only by the sweep, for the same reason `Open` judges it: the
// sweep runs on a tick, and between two ticks the row still says `ready` while the link it publishes
// already answers 410. A poll and the address it hands out must not disagree about whether the thing
// exists — the canon's own promise is that after `ready` the only move is to `expired`, and a client
// watching the poll would never see it happen.
func (s *Service) Read(ctx context.Context, userID, bookID, exportID string) (pgstore.Export, error) {
e, err := s.Store.ReadExport(ctx, userID, bookID, exportID)
if err != nil {
return e, err
}
return s.asOfNow(e), nil
}
// asOfNow reports a `ready` row that is no longer serveable as what it already is, and it answers
// the SAME question `Open` answers — which is the whole point: a poll and the address it hands out
// must not disagree about whether the thing exists.
//
// TWO ways to stop being serveable, and the second was found by the acceptance. The moment passed:
// the sweep runs on a tick, and between two ticks the row still says `ready` while the link is
// already refused. Or THE FILE IS GONE: an operator's cleanup, a lost volume — `Open` answers 410
// for it and the poll went on advertising a link for up to a whole TTL.
//
// It does NOT write. Reclaiming the row and the bytes is the sweep's job, and a read that wrote
// would be a read that can fail for a reason its caller cannot act on.
func (s *Service) asOfNow(e pgstore.Export) pgstore.Export {
if e.State != pgstore.ExportReady {
return e
}
lapsed := e.ExpiresAt != nil && !s.now().Before(*e.ExpiresAt)
if !lapsed && e.Path != "" {
// One stat per poll, and it buys the agreement above. ⚠ Only ErrNotExist counts: any other
// error (a permission, a busy mount) is this deployment's problem and must not be reported to
// a client as "your export expired".
if _, err := os.Stat(e.Path); err != nil && errors.Is(err, os.ErrNotExist) {
lapsed = true
}
}
if lapsed {
e.State, e.Path = pgstore.ExportExpired, ""
}
return e
}
// Open resolves a download to an open file, or says which of the three "not now" answers applies.
//
// The file is opened rather than stat'ed: between a stat and a read the sweep can delete it, and a
// handle already open survives the unlink — which is what makes an expiry that fires mid-download
// harmless instead of a truncated book.
func (s *Service) Open(ctx context.Context, userID, bookID, exportID string) (*os.File, pgstore.Export, error) {
stored, err := s.Store.ReadExport(ctx, userID, bookID, exportID)
if err != nil {
return nil, pgstore.Export{}, err
}
e := s.asOfNow(stored)
switch e.State {
case pgstore.ExportExpired:
return nil, e, ErrExpired
case pgstore.ExportReady:
default:
return nil, e, ErrNotReady
}
f, err := os.Open(e.Path)
if errors.Is(err, os.ErrNotExist) {
// The row said ready and the bytes went between `asOfNow` and here. Answered as EXPIRED and not
// as a 500: from the caller's seat the remedy is the same one, build it again, and a 500
// invites a retry that can never succeed.
s.log().ErrorContext(ctx, "an export marked ready has no file on disk", "export", exportID)
return nil, e, ErrExpired
}
if err != nil {
return nil, e, fmt.Errorf("exports: open artifact: %w", err)
}
return f, e, nil
}
// artifactPath is where one export's file lives. Under the book so that a book's artifacts can be
// found and removed together, and named by the export's own id so that two exports of one book in
// one format are two files — which is what the canon means by «a second export under a different
// key is a second artifact».
func (s *Service) artifactPath(bookID, exportID, format string) string {
return filepath.Join(s.Cfg.Dir, bookID, exportID+"."+format)
}
// Build is the worker. It is handed an id and reads everything else from the store, so a job
// re-driven after a restart cannot carry a stale copy of anything.
func (s *Service) Build(ctx context.Context, exportID string) error {
row, err := s.Store.ReadExportForBuild(ctx, exportID)
if err != nil {
if errors.Is(err, pgstore.ErrNoExport) {
// The book was deleted under the job, or the row never committed. Nothing to do and
// nothing wrong: the queue must not retry a job whose object is gone.
s.log().InfoContext(ctx, "an export job names a row that is not there", "export", exportID)
return nil
}
return err
}
if row.State != pgstore.ExportPending {
// The stale sweep already ended this poll, or a duplicate worker won. Either way the verdict
// stands and building would produce a file nobody will serve.
s.log().InfoContext(ctx, "an export job found its row already settled",
"export", exportID, "state", string(row.State))
return nil
}
// The claim is what tells the GC apart from itself: from here the row is judged on how long the
// BUILD has been silent, not on how long it waited in a queue behind three parses. It carries the
// `pending` guard too, so the read above and this write cannot disagree across the gap between them.
if err := s.Store.ClaimExportBuild(ctx, exportID, s.now()); err != nil {
if errors.Is(err, pgstore.ErrExportSettled) {
s.log().InfoContext(ctx, "an export job was settled between its read and its claim", "export", exportID)
return nil
}
return err
}
if !row.HasTree {
// ⚠ THE ENGINE IS NOT ASKED, and that is the point of judging here. Both shapes of «not cut
// yet» reach it as the WRONG class: a book still being received has no `book.yaml` and comes
// back exit 10, which this door reads as the deployment's fault (measured: `tmctl build` on a
// directory without a configuration exits 10, «config: read …: no such file or directory»);
// a book whose source cut into nothing comes back exit 11, which is the number the INTAKE acts
// on destructively. Neither is what happened, and neither is a word to put in front of a user.
//
// ⚠ AND THE ARTIFACT CANNOT EXIST, which is why this is a verdict and not a marked file. The
// engine refuses a book with no output units BEFORE the assembler and `--partial` does not
// lift it — measured, exit 11, nothing written — and its own reason is the right one: «a spine
// with no document is not an EPUB, and a text file of nothing is not a book»
// (backend/internal/pipeline/bookbuild.go). A file built anyway would fail `epubcheck`, which
// is the control this door is accepted against.
s.fail(ctx, exportID, FailureBookEmpty,
"the book has not been cut into chapters, so there is nothing to build from", nil)
return nil
}
out := s.artifactPath(row.BookID, exportID, row.Format)
if err := os.MkdirAll(filepath.Dir(out), 0o750); err != nil {
s.fail(ctx, exportID, FailureBuildFailed, "the export directory could not be made", err)
return nil
}
// Anything the engine staged here and did not commit — see discard. Cleared BEFORE the build as
// well as after it, because the leftovers of a killed predecessor are what this pass would
// otherwise inherit and never look at again.
s.discard(ctx, out)
res, runErr := s.Engine.Build(ctx, s.Cfg.EngineBinary, row.Workdir, row.Format, out)
if code, ok := s.verdict(ctx, exportID, res, runErr); !ok {
// The verb did not produce a file. Whatever it left behind is not an artifact.
s.discard(ctx, out)
s.fail(ctx, exportID, code, "the export could not be built", runErr)
return nil
}
s.report(ctx, exportID, row.BookID, res.Report)
fi, err := os.Stat(out)
if err != nil {
// The engine says it wrote the file and it is not there. Loud, and failed rather than
// published: a `ready` row over nothing is a link to a 404.
s.fail(ctx, exportID, FailureBuildFailed, "the built export is not where the engine said it wrote it", err)
return nil
}
now := s.now()
// ⚠ ON A CONTEXT OF ITS OWN, exactly as `fail` writes its verdict — and the asymmetry this fixes
// was real: the verb can come back with a file COMMITTED to disk and a context already dead (the
// job's deadline, a shutdown), and publishing on that context then fails, deletes a book that was
// built, and leaves the row `pending` for the stale sweep to end minutes later with a less true
// reason. The irreversible act is done, so its record must survive any later cancellation on the
// same path.
settle, cancel := context.WithTimeout(context.WithoutCancel(ctx), settleBudget)
defer cancel()
err = s.Store.FinishExport(settle, exportID, out, fi.Size(), res.Report.Complete, now, now.Add(s.Cfg.TTL))
if errors.Is(err, pgstore.ErrExportSettled) {
// The stale sweep ended the poll while this build was running. The client has already been
// told `failed`, so the file must not stay on disk: nothing will ever serve or expire it.
s.log().WarnContext(ctx, "a build finished after its export had been given up on; its file is removed",
"export", exportID)
s.discard(ctx, out)
return nil
}
if err != nil {
// The file EXISTS and the row does not know it. Returning the error would have the queue
// count a failure; what it would not do is delete the file, so it is deleted here — the row
// is the only thing that can ever find it again, and the stale sweep will end the poll.
s.discard(ctx, out)
return err
}
s.log().InfoContext(ctx, "export built", "export", exportID, "book", row.BookID,
"format", row.Format, "bytes", fi.Size(), "complete", res.Report.Complete)
return nil
}
// discard removes what a build left that is not an artifact: the output path itself, and the
// engine's own STAGING files beside it.
//
// The staging half is not tidiness. The engine writes every format through `os.CreateTemp(dir,
// "."+base+".tmp-*")` and renames it into place (backend/internal/pipeline/artifact.go,
// `stageFileAtomic`), and `build` cannot be wound down gracefully (see runner.buildStopGrace) — so a
// deadline kills it with SIGKILL, and a kill between the write and the rename leaves a dot-file in
// the export directory that NOTHING else will ever name again: no row carries it, and the GC deletes
// only paths rows carry. One glob per build is the whole price of not accumulating them.
//
// Failures are logged and never returned: this runs on paths that are already reporting something
// else, and a cleanup that could fail the caller would turn a housekeeping problem into a user's.
func (s *Service) discard(ctx context.Context, out string) {
if err := os.Remove(out); err != nil && !errors.Is(err, os.ErrNotExist) {
s.log().WarnContext(ctx, "an export that was not published could not be removed", "err", err)
}
staged, err := filepath.Glob(filepath.Join(filepath.Dir(out), "."+filepath.Base(out)+".tmp-*"))
if err != nil {
return // the pattern is ours and cannot be malformed; nothing to say
}
for _, f := range staged {
if err := os.Remove(f); err != nil && !errors.Is(err, os.ErrNotExist) {
s.log().WarnContext(ctx, "a staging file of a killed build could not be removed", "err", err)
}
}
}
// verdict maps the verb's exit onto "there is a file" plus, when there is not, the machine reason.
//
// ⚠ The exit code is read as DATA and never as band membership. `build` reaches the refusal band
// through classes whose remedies are opposite — an operator's configuration (10), a book with no
// text (11), a project another process owns (12) — and the one destructive consumer of that band in
// this module is the intake, which this path must never resemble.
func (s *Service) verdict(ctx context.Context, exportID string, res runner.BuildOutcome, runErr error) (string, bool) {
if !res.Exited {
// Killed on a deadline, or never started. Nothing was written that this side would keep.
s.log().ErrorContext(ctx, "the build verb did not exit on its own",
"export", exportID, "err", runErr, "stderr", res.Stderr)
return FailureBuildInterrupted, false
}
switch res.ExitCode {
case ingest.ExitClean, ingest.ExitFlagged:
// Exit 2 is an ANSWER here for the same reason it is one for `status` and `export`: a book
// with a flagged unit is the ordinary case, and reading it as a refusal would leave exactly
// those books without a downloadable copy. ⚠ `build` does not emit it today (its renderer
// carries no flagged sentinel), and the rule is applied rather than the invariant depended
// on: were a later engine to flag during a build, this door would refuse every book that
// ever flagged a unit.
if !res.Decoded {
s.log().ErrorContext(ctx, "a completed build carried no report",
"export", exportID, "decode_err", res.DecodeErr, "stderr", res.Stderr)
return FailureBuildFailed, false
}
if res.Report.Version != ingest.KnownBuildVersion {
// Read anyway — the file exists and is paid for — but say so: a shape this build has not
// seen is how a deploy skew announces itself.
s.log().WarnContext(ctx, "the build report carries a shape this platform was not written against",
"export", exportID, "build_version", res.Report.Version, "known", ingest.KnownBuildVersion)
}
return "", true
case ingest.ExitBookIncomplete:
// Unreachable through this door: it always passes `--partial` (BuildArgs). Reaching it means
// the flag did not survive to the engine, which is a wiring fault of THIS side and not a
// book with holes.
s.log().ErrorContext(ctx, "the build verb refused an incomplete book, so --partial did not reach it",
"export", exportID, "stderr", res.Stderr)
return FailureBuildFailed, false
case ingest.ExitSourceUnreadable:
// From `build` this is one specific thing and NOT the intake's meaning: the book has no
// output units to write (bookbuild.go). Nothing is deleted, nothing is rejected — the user is
// told the book is empty.
s.log().WarnContext(ctx, "a build found no output units in the book",
"export", exportID, "stderr", res.Stderr)
return FailureBookEmpty, false
case ingest.ExitConfigInvalid:
// The operator's file: a format this engine does not know, a book.yaml with no title or no
// language tag, an `--out` the host will not take.
s.log().ErrorContext(ctx, "the build verb refused this deployment's configuration: a format this "+
"engine does not build, a book.yaml without a language tag, an unreadable reader-words file, "+
"or an --out this host will not take",
"export", exportID, "stderr", res.Stderr)
return FailureDeploymentError, false
case ingest.ExitProjectLocked:
// Genuinely transient: another process holds the project for a moment. ⚠ NOT expected —
// `build` opens read-only and takes no flock — so a lock here means the project's database did
// not exist and the engine fell back to a full open (openRunner), which a live run would then
// be holding.
s.log().ErrorContext(ctx, "the build verb met a project another process holds",
"export", exportID, "stderr", res.Stderr)
return FailureBuildInterrupted, false
case ingest.ExitSchemaMismatch:
// ⚠ It reads as «come back later» to the caller and it is NOT self-healing: an OPERATOR owes
// `tmctl migrate` before any retry can work. Grouping it with the transient class was an
// adversarial pass's finding — the comment promised the user a convergence that only a person
// can produce. The wire word stays the same (from the caller's seat the remedy really is «not
// now»), and the LOG is what says who has to act.
s.log().ErrorContext(ctx, "the build verb refused: the book's project schema is not this "+
"engine's, and no retry clears that — an operator owes `tmctl migrate` (deploy order, row 174)",
"export", exportID, "stderr", res.Stderr)
return FailureBuildInterrupted, false
case ingest.ExitWriteIncomplete:
// `build` reaches class 15 of its own (bookbuild.go: the copies were prepared and a commit did
// not complete), so it is mapped rather than left to the default — whose log line would say
// the verb answered outside this door's contract, which is false. Nothing is published: what
// landed is half a set, and the remedy is to ask again.
s.log().ErrorContext(ctx, "the build verb prepared the copies and did not land them all",
"export", exportID, "stderr", res.Stderr)
return FailureBuildFailed, false
case ingest.ExitStopped:
// ⚠ UNREACHABLE FROM `build` TODAY and mapped anyway. `tmctl build` is called without the
// signal context its siblings get (backend/cmd/tmctl/main.go, `case "build"`), so a SIGTERM
// is observed by nothing and the verb never winds itself down. The mapping stands because the
// number belongs to the engine's vocabulary rather than to this call site: the day `build`
// learns to take the context, this door already knows what the answer means.
s.log().WarnContext(ctx, "the build verb reported being wound down mid-call", "export", exportID)
return FailureBuildInterrupted, false
}
s.log().ErrorContext(ctx, "the build verb answered outside this door's contract",
"export", exportID, "exit", res.ExitCode, "stderr", res.Stderr)
return FailureBuildFailed, false
}
// report is the ratified second duty of this door: it checks the REPORT and not only the file
// (D39.175 п.2, D39.178 п.3). Everything here is addressed to the OPERATOR and to the interface —
// never to the reader, whose honesty is written inside the file — and no money is in it (D39.84).
func (s *Service) report(ctx context.Context, exportID, bookID string, rep ingest.BuildReport) {
if !rep.NeedsOperator() {
return
}
s.log().WarnContext(ctx, "a built export carries something for the operator: the file is the text the run shipped",
"export", exportID, "book", bookID,
"config_drift", rep.ConfigDrift, "stale_unknown", rep.StaleUnknown,
"removed_files", len(rep.RemovedFiles), "stale_copies", len(rep.StaleCopies))
}
// fail records a machine reason and ENDS the poll. It never returns an error to the queue: the
// remedy for a failed build is the user asking again, and a queue retry would build a second file
// for a row that already carries a verdict.
func (s *Service) fail(ctx context.Context, exportID, code, why string, cause error) {
s.log().ErrorContext(ctx, "export failed: "+why, "export", exportID, "failure_code", code, "err", cause)
// On a context of its OWN: the caller that will poll is precisely the one whose request is over,
// and on a cancelled context the verdict would not be written at all — leaving a `pending` row
// for the stale sweep to end minutes later with a less true reason.
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), settleBudget)
defer cancel()
if err := s.Store.FailExport(c, exportID, code, s.now()); err != nil &&
!errors.Is(err, pgstore.ErrExportSettled) {
s.log().ErrorContext(c, "an export's failure could not be recorded", "export", exportID, "err", err)
}
}
// settleBudget is what writing one verdict may take after the caller is gone. The same figure and
// the same reasoning as the idempotency receipt's.
const settleBudget = 10 * time.Second
// Sweep is the GC: it takes away what has lapsed and ends the polls of what nobody is coming back
// for. It rides the reconciler's tick because both questions are one indexed statement plus a few
// unlinks.
func (s *Service) Sweep(ctx context.Context) error {
now := s.now()
// First the ROWS: a link must stop working at the moment it was promised to, and that is a
// statement the row makes. The bytes go second and separately — see below.
lapsed, err := s.Store.ExpireExports(ctx, now)
if err != nil {
return err
}
// Then the BYTES, of everything expired that still names a file — including what a previous pass
// could not delete. ⚠ The retry is the point: an unlink fails on a full disk, a read-only mount or
// a crash between the two acts, and the earlier shape cleared the path in the same statement as
// the state, so a file that survived its unlink could never be named again by anything. Every
// other read of this table selects `ready`.
owed, err := s.Store.Unlinked(ctx)
if err != nil {
return err
}
removed := 0
for _, a := range owed {
if err := os.Remove(a.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
// Left for the next pass ON PURPOSE, with its path still on the row: this is the one thing
// that used to leak silently.
s.log().WarnContext(ctx, "an expired export's file could not be removed; it stays on the row for the next pass",
"export", a.ID, "err", err)
continue
}
if err := s.Store.ForgetExportPath(ctx, a.ID); err != nil {
// The bytes are gone and the row still names them: the next pass tries to remove a file that
// is not there, which is the harmless direction of this pair.
s.log().WarnContext(ctx, "an artifact was removed and its row still names it", "export", a.ID, "err", err)
continue
}
removed++
}
if lapsed > 0 || removed > 0 {
s.log().InfoContext(ctx, "expired exports reclaimed", "rows", lapsed, "artifacts", removed, "owed", len(owed))
}
ids, err := s.Store.FailStaleExports(ctx, FailureBuildInterrupted,
now.Add(-s.Cfg.StaleAfter), now.Add(-s.Cfg.QueuedGrace), now)
if err != nil {
return err
}
if len(ids) > 0 {
// A build silent longer than one whole job — or queued longer than any backlog should last —
// means nobody is coming back: the queue does not retry this kind, so nothing else would ever
// end these polls.
s.log().WarnContext(ctx, "export builds nobody came back for were given up on", "exports", len(ids))
}
return nil
}