618 lines
32 KiB
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
|
|
}
|