// Package runs owns the life of a translation run: admitting one, spawning its transient systemd // unit, tailing what it writes, and deciding what happened to it afterwards. // // The shape follows from D39.106: the engine is NOT a child of this service, so no component here // waits on a process. Admission (the queue) only hands out permission to start; everything after // that is a RECONCILER that reads the world — Postgres, the book's journal, the exit marker — and // moves the read model to match. That is what makes a run survive a deploy: the process that // started it and the process that finishes it are usually not the same process. package runs import ( "context" "errors" "fmt" "log/slog" "sync" "time" "textmachine/platform/internal/ingest" "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/pricing" "textmachine/platform/internal/runner" ) // UnitRunner is the transient-unit half of the seam. An interface so the reconciler's decisions can // be pinned without systemd, and so a test that DOES want systemd is obviously a different test. type UnitRunner interface { Start(ctx context.Context, s runner.Spec) error Stop(ctx context.Context, unit string) error Alive(ctx context.Context, unit string) (bool, error) } // EngineStatus is the ratified repair channel: `tmctl status --json` against a book. type EngineStatus interface { Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error) } // BankApplier is the correction channel: `tmctl bank-apply` against a book (D39.156 pack 2в). An // interface for the same reason EngineStatus is one: the door's decisions are pinned without an // engine binary, and a test that wants the real verb is visibly a different test. type BankApplier interface { BankApply(ctx context.Context, binary, workdir, decisionsPath string, preview bool) (runner.BankApplyOutcome, error) } // Config is everything the runner needs that an operator chooses. type Config struct { // StateDir is where exit markers live. Platform state, deliberately NOT the book's directory: // the engine owns that one and the platform does not write into it (D39.110). StateDir string // EngineBinary is the VERSIONED path of tmctl (unified backlog row 139). An attempt is pinned to // the path it was started with, so a deploy of the engine mid-run cannot change what a resumed // attempt executes. EngineBinary string // MarkerArgv is the command systemd runs as ExecStopPost to write the exit marker. MarkerArgv []string // Ceiling is how the run's ceiling reaches the engine, as the argv template of the landed flag // (row 145). Empty means the configured override rendered nothing — whitespace, say, since an // unset variable takes the default — and then starting a run is refused rather than started under // the book's own limit. Ceiling runner.CeilingTemplate // KeysFile is the deployment's provider-key file, passed to `translate` as `--keys-file` // (row 211). The path is an engine argument: keys are never put into the unit's environment and // never pass through this process. Empty passes no flag. KeysFile string // MemoryMax and TasksMax bound ONE run's cgroup — the answer to PD-13. MemoryMax string TasksMax int // AllowEngineVersionChange lets a RESUME run a different engine build than the one its run // started with. Off by default and it has to be turned on deliberately (unified backlog row 139): // the engine is deployed more often than a translation finishes, so the quiet behaviour would be // for a resumed run to continue under a program nobody chose for it. AllowEngineVersionChange bool // RunBudget is what ONE run may cost a pass of the sweep; zero takes the default. It bounds the // starvation the pass itself cannot: the list is ordered the same way every time, so a handful of // runs whose engine hangs used to mean the tail was never reached (register row PD-169). RunBudget time.Duration // ResyncEvery is how often a live run is reconciled from `tmctl status --json`. It is a SLOW // poll on purpose: every call re-ingests and re-chunks the source (~1.4-1.5 s of CPU on a 23 MB // book, unified backlog row 100). Since the emitter landed (D39.131) it is the REPAIR channel and // not the source of progress — see maybeResync for which runs still reach it. ResyncEvery time.Duration } // Service is the run lifecycle. type Service struct { Store *pgstore.Store Runner UnitRunner Engine EngineStatus Bank BankApplier Pricing pricing.Model Queue Enqueuer Cfg Config Log *slog.Logger // Now is injectable so the reconciler's clocks are testable without sleeping. Now func() time.Time // resynced remembers when each run was last reconciled from status. In memory on purpose: it is // a rate limit, not a fact — losing it on restart costs one extra status call. resynced map[string]time.Time // parkedSaid rate-limits the one line a PARKED attempt produces, keyed by attempt. The state it // reports can last a run's whole life and is not written down anywhere, so neither extreme is // right: said every pass it is the line an operator filters (this file's own rule, deferItem), // said once it is invisible to anyone who starts watching afterwards — and unlike a deferral, // nothing else carries the fact. Said at the crossing and then no more often than the repair // channel speaks. parkedSaid map[int64]time.Time // books serializes one book's admissions, resumes and corrections against each other — the // per-book rule of the correction door (see bank.go, lockBook). In memory on purpose: the // sections it guards live inside one process's calls. booksMu sync.Mutex books map[string]*bookLock } // Enqueuer hands a run to the queue. // // Two methods because the two callers need different atomicity, not different queues: an ADMISSION // writes the run, its hold and its queue entry in one transaction (a job for a run that does not // exist, or a hold with no job, are both ways to lose money or work), while a RESUME re-opens a run // that already exists and only then asks for a worker. A lost entry costs a sweep interval either // way — the reconciler is the backstop for both. type Enqueuer interface { EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error EnqueueRunNow(ctx context.Context, runID string) error } func (s *Service) now() time.Time { if s.Now != nil { return s.Now() } return time.Now() } func (s *Service) log() *slog.Logger { if s.Log == nil { return slog.New(slog.DiscardHandler) } return s.Log } // ErrCeilingOutOfBounds is a requested ceiling the account or the book cannot carry. It is the // contract's 409 on a run start: the bounds are read by GET run-options and may move between that // read and this call, because a hold taken for another book lowers what is left. var ErrCeilingOutOfBounds = errors.New("runs: the requested ceiling is outside the bounds") // ErrRePassUnavailable — a re-pass was asked for on a book that has nothing to re-pass: the bank // has not moved since the last run, or its move touched no already-paid unit. The remedy is the // options read, which is what says whether a re-pass exists to buy (P10 §3.2). var ErrRePassUnavailable = errors.New("runs: the book has nothing to re-pass") // ErrRunnerIncomplete is a DEPLOYMENT that cannot start runs — the exit-marker command is missing, // so a unit that ended would have no way to say so and its hold would stay reserved forever. // // It is a refusal of the run and NOT of the service: an instance whose runner is misconfigured still // serves every read, and taking the library down with it would turn a partial outage into a total // one during exactly the operation that causes it, a binary upgrade. var ErrRunnerIncomplete = errors.New("runs: this deployment cannot record how a run ends") // ErrBookNotReady is a run asked for on a book whose intake has not finished — one still arriving, // still being cut, or rejected. The contract's 409: the book exists and the client can see it, and // what cannot happen yet is a translation of it. var ErrBookNotReady = errors.New("runs: the book is not ready to be translated") // ErrNotStoppable is a stop asked for on a run that is already over. var ErrNotStoppable = errors.New("runs: the run is not live") // ErrNotResumable is a resume that would not move the run: a run that is already going, or one that // finished. A bank stop is NOT among them — signing is one act over the whole bank and `resume` // lifts that stop with the decisions as they stand (D39.144). var ErrNotResumable = errors.New("runs: the run cannot be continued") // ErrCeilingReached is a resume of a run that stopped at a LIMIT — the chapters it bought, or the // credit behind them. It is ErrNotResumable with the one cause the contract enumerates, because the // remedy differs and is the thing a client has to be told: a limit travels with the START of a run, // so a NEW run with a larger one continues the work and this call never will. var ErrCeilingReached = fmt.Errorf("%w: it stopped at a limit", ErrNotResumable) // ErrCreditUnavailable is a resume of a run that has room LEFT in its limit, over an account that // cannot cover the rest of it. Told apart from ErrCeilingReached because the remedies are opposite: // this one clears when the account is topped up and the SAME run then continues, while that one is // finished with and needs a new run. Answering either in place of the other sends the user to do the // wrong thing (canon §resumeRun). var ErrCreditUnavailable = fmt.Errorf("%w: the account cannot cover the rest of it", ErrNotResumable) // CreditHeldError is ErrCeilingOutOfBounds with the book that caused it: another book of the account // has a run holding the credit, and naming it is what lets a client offer the user somewhere to go. type CreditHeldError struct{ BookID string } func (e *CreditHeldError) Error() string { return "runs: the account's credit is held by another book" } func (e *CreditHeldError) Is(target error) bool { return target == ErrCeilingOutOfBounds } // ErrNotPriced is a book the engine has not priced, so no order over it can be honest. // // ⛔ A REFUSAL AND NOT A FALLBACK, and this is the line the per-chapter constant used to stand on. // $0.03 a chapter was measured 4.47× low (D39.179 §1) and it made the last chapters of every book // unbuyable at any balance (PD-440); replacing it with «guess again, more carefully» would keep the // shape of the defect. There is now one source for what a book costs — the engine's own projection, // published in `manifest --json` — and when that source has not spoken this platform says so. // // It clears by itself: the book's reading surface is owed the moment a manifest can be read, and the // materializer's drain writes the projection with the tree. An operator can force it with // `tmplatformctl book refresh --book `. var ErrNotPriced = errors.New("runs: this book has no price projection, so an order over it cannot be quoted") // Options is the whole order form for one book: what is left, what the balance covers, and the quote // for the default order — the whole book (D39.196 §1). type Options struct { pricing.Options // BlockedBy is the book whose hold is holding this one's order down, or "". BlockedBy string // Structure is where the chapter cut came from, verbatim from the engine. ChapterOrders says // whether an order may be phrased in CHAPTERS against it at all — see ingest.ChapterOrdersOffered, // which trusts `detected` alone. Structure string ChapterOrders bool // SourceChars is what is left to translate, in characters — the unit an order takes when chapter // orders are not offered. SourceChars int64 } // Order computes the order form for a book: what it costs, what the balance covers, and what the // buyer is told before the click. func (s *Service) Order(ctx context.Context, userID, bookID string) (Options, error) { // ⚠ OWNERSHIP IS THIS QUERY'S OWN, not a separate check before it. `ReadBookForOrder` selects // `where b.id = $1 and b.owner_id = $2` and answers ErrNoBook for a stranger's book exactly as // for a missing one — telling them apart is what would let anyone enumerate other people's // libraries. A guard call ahead of it read the same row through a lateral join to say the same // thing, on the path a client polls. priced, err := s.Store.ReadBookForOrder(ctx, userID, bookID) if err != nil { return Options{}, err } acct, err := s.Store.ReadAccount(ctx, userID) if err != nil { return Options{}, err } if !priced.Priced { return Options{}, ErrNotPriced } held, heldAmount, err := s.Store.CreditHeldBy(ctx, userID, bookID) if err != nil { return Options{}, err } book := priceBook(priced) // Balance AS IT IS. A hold is a debit when it is taken, so the balance already excludes the holds // open against it and subtracting Reserved a second time would halve the scale (D39.115 §2a). out := Options{ Options: s.Pricing.Order(book, acct.Balance), Structure: priced.Structure, ChapterOrders: ingest.Manifest{Structure: priced.Structure}.ChapterOrdersOffered(), } for _, c := range book.Remaining { out.SourceChars += c.SourceChars } // `blocked` answers "why is the order SMALLER than this account could afford" (canon §RunOptions), // so a hold that costs this book nothing must not name another book the user would then go and // stop for nothing. if held != "" && s.Pricing.Affordable(book, acct.Balance+heldAmount) > out.AffordableChapters { out.BlockedBy = held } return out, nil } // priceBook maps the store's rows onto the arithmetic's own shape. It is a mapping and not a second // derivation: every figure in it was written by the materializer from one manifest. func priceBook(p pgstore.PricedBook) pricing.Book { book := pricing.Book{StepMax: p.StepMax, BookOnce: p.BookOnce, Remaining: make([]pricing.Chapter, 0, len(p.Remaining))} for _, c := range p.Remaining { book.Remaining = append(book.Remaining, pricing.Chapter{ ID: c.ID, Number: c.Number, Units: c.Units, Expected: c.Expected, SourceChars: c.SourceChars, }) } return book } // ErrBalanceCannotCarry is an order the account's money does not reach. // // ⛔ ITS OWN ERROR BECAUSE ITS REMEDY IS ITS OWN, and folding it into ErrCeilingOutOfBounds sent the // buyer to do the wrong thing. That error means «the options moved between the read and the call», // and the wire word for it, `bounds_moved`, promises a client that re-reading and retrying will // work. Here nothing moved and retrying is futile: what is missing is money, and the remedy is to // top up. This is the ONE refusal the whole order form exists to make honest — it is the answer to // «хватает или нет» arriving after the click — and it was travelling under somebody else's word. var ErrBalanceCannotCarry = errors.New("runs: the account's balance does not carry this order") // ErrChapterOrdersUnavailable is an order phrased in CHAPTERS over a book whose chapter cut is not // trustworthy enough to sell against — see ingest.ChapterOrdersOffered, which trusts `detected` // alone. The remedy is in the options read: such a book takes an order in CHARACTERS, or the whole // book, and the client is told the structure word so it can say why. var ErrChapterOrdersUnavailable = errors.New("runs: this book's chapter boundaries cannot be ordered against") // StartRequest is one accepted call of POST /books/{id}/runs. // // THE THREE KINDS OF ORDER (D39.196 §1, unified backlog row 279), and nothing chooses between them // by guessing: the whole book (both members nil, the default — «не тронута ни одна ручка ⇒ заказ = // вся книга»), a number of CHAPTERS, or a number of CHARACTERS. A re-pass is a fourth thing and buys // no volume at all. type StartRequest struct { UserID string BookID string VerifyBank bool // Chapters is how many of the REMAINING chapters to buy; nil is the whole book. It is refused on a // book whose cut is not trusted, because there «chapter 12» does not name what a buyer thinks. Chapters *int // Characters is how much SOURCE TEXT to buy, in runes. It resolves to the shortest prefix of units // that reaches it — units being the granularity the engine ships and stops at — and it is the only // partial order a book with one chapter can carry. Characters *int64 // RePass buys the re-pass instead of volume (P10, D39.165 §3): legal only when the options // announced work (the bank moved and touched paid units), it admits a run whose hold is the // materialized projection and whose bar walks the book. Chapters and Characters are ignored. RePass bool } // Start admits a run. // // The order is re-judged here and not trusted from the client: the options read is a READ, the // balance moves under it — another book's hold lowers what is left — and the number that decides how // much money is reserved cannot be one the caller chose unilaterally. func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, error) { // Refused BEFORE the money moves: a run the engine cannot be given a ceiling for would spend // under the book's own limit instead of the account's, and that is not a difference to discover // after the hold (see runner.CeilingTemplate). if err := s.runnable(); err != nil { return pgstore.Run{}, err } // The book's own serialization (bank.go): a run admitted while the correction door is mid-verb // on this book would spawn a `translate` straight into the verb's flock — a wasted attempt. The // door's own budget bounds each queued call, and the caller's context bounds THIS wait. unlock, err := s.lockBook(ctx, in.BookID) if err != nil { return pgstore.Run{}, err } defer unlock() book, err := s.Store.ReadBookForRun(ctx, in.UserID, in.BookID) if err != nil { return pgstore.Run{}, err } if !readyToTranslate(book.Status) { // A book whose intake has not finished has no chapter tree to translate and, while it is // `uploading`, half a file on disk. Refused here rather than discovered by the engine inside a // transient unit, where the only trace would be a marker reading "exit-code 1" — and after the // account's money had already been held for it. return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status) } if book.ChapterCount > 0 && !book.HasTree { // The book DECLARES chapters and its tree has not been materialised: the intake committed // `not_started` and the materialisation that follows it — in a separate transaction, outside // the intake's — failed or has not run. Refused HERE, before the hold, because there is no // honest bar for such a run and no way out of it either (PD-405). Every counter the screen // shows is a count over `chapters`, so the run would read 0/total for its entire life while // spending; and the tree's own debt is FROZEN while it runs, because the sweep that pays that // debt skips a book with a live run. The window is the intake's normal one — measured at // 0.018 s on the healthy path — and unbounded exactly when the materialisation broke, which is // the only shape worth refusing. // // `book_not_ready` is the word for it, not a new one: the canon's own gloss is "still // arriving, still being cut, or was rejected", and a book that owes its tree is still being // cut. The remedy is to wait for the intake, which is what the status says. // // ⚠ ONE population does not clear by waiting, and the operator's handle for it exists: a book // whose reading-surface debt was WRITTEN OFF after its attempts (AbandonReadModelDebt) owes // nothing, so no sweep will build its tree and this refusal stands until somebody asks again — // `tmplatformctl book refresh --book `, which is exactly what that command is for, and the // books behind it are listed by `tmplatformctl books --abandoned`. Named here because this // guard is what turns a book that merely showed a frozen bar into one that cannot be started: // the refusal is the honest half, and being able to find the remedy is the other. return pgstore.Run{}, fmt.Errorf("%w: its chapters are still being materialised", ErrBookNotReady) } if book.HasLiveRun { return pgstore.Run{}, pgstore.ErrRunInFlight } acct, err := s.Store.ReadAccount(ctx, in.UserID) if err != nil { return pgstore.Run{}, err } priced, err := s.Store.ReadBookForOrder(ctx, in.UserID, in.BookID) if err != nil { return pgstore.Run{}, err } if !priced.Priced { // No projection, no sale. See ErrNotPriced: the alternative is the constant that made the last // chapters of every book unbuyable, and a quieter version of it would be worse, not better. return pgstore.Run{}, ErrNotPriced } pb := priceBook(priced) // The re-pass consents, decided HERE — under the book lock, once — and stored on the run row so // every spawn of every attempt renders the same argv (P10 §3.1). // // ⛔ `--resnapshot` NOW RIDES EVERY CONTINUATION, not only a run over an edited bank, and that is // PD-422 closed rather than a widening. The auto-bank grows by MINING during an ordinary run; // mining moves the ENRICHED memory version; the enriched version is folded into the EDIT wave's // snapshot alone (backend/internal/pipeline/snapshot.go, snapshotIDForWave). So the SECOND // purchase of a mining book meets its own already-pinned edit jobs under a moved snapshot and the // drift guard stops the engine — `exit 1`, which this platform can only report as `failed`, after // the hold was taken and with nothing translated. The condition used to be the correction door's // flag alone, which no amount of mining ever sets. // // ⚠ Passing it where nothing moved costs NOTHING: the engine re-pins only where a snapshot // actually differs (stagerun.go compares per job), and the guard is per JOB, so it never concerns // a chapter this run does not touch. And the volume ceiling admits NEW book before re-made book // (volume.go, the unitFresh-then-unitRework pass), so a continuation spends its grant on // undelivered chapters rather than on re-making the beginning of the book. resnapshot := book.BankMoved || book.HasPriorRun if in.RePass { // The re-pass purchase (P10 §3.2): «a re-pass costs up to your hold». What it would actually // cost is unknowable at admission (errata 28.08-к) — the engine's status is blind to a // correction until the next translate folds the bank in — so the hold is the honest ceiling of // the work bought: the WHOLE book's projection, of which untouched units come back at $0 and // the difference is released on settlement. The consent cap equals the hold: funded by // construction, and never the bare blanket form. if !book.BankMoved { return pgstore.Run{}, ErrRePassUnavailable } hold, bondFunded := s.Pricing.Hold(priced.Expected-priced.BookOnce, priced.StepMax, priced.BookOnce, acct.Balance) if hold > acct.Balance { return pgstore.Run{}, fmt.Errorf("%w: a re-pass of this book reserves %s", ErrBalanceCannotCarry, hold.USD()) } offset, err := journalSize(book.Workdir) if err != nil { return pgstore.Run{}, err } started, err := s.Store.StartRun(ctx, pgstore.StartRunInput{ UserID: in.UserID, BookID: in.BookID, OrderedChapters: 0, Ceiling: hold, Now: s.now(), Resnapshot: true, AcceptRebill: hold, // ⚠ RECORDED HERE TOO, and its absence was a lie about a whole class of runs: a re-pass // holds the WHOLE book's projection, so it funds the book-level pass more often than any // ordinary purchase — and the field that exists so the platform does not stay silent about // an unfunded pass was reporting every re-pass as unfunded. BondFunded: bondFunded, }, offset, s.enqueue) if err != nil { return pgstore.Run{}, err } s.log().InfoContext(ctx, "re-pass admitted", "run", started.ID) return started.Run, nil } quote, order, err := s.resolveOrder(ctx, in, priced, pb, acct.Balance) if err != nil { return pgstore.Run{}, err } if quote.Chapters == 0 { return pgstore.Run{}, fmt.Errorf("%w: there is nothing left of this book to buy", ErrCeilingOutOfBounds) } if quote.Hold > acct.Balance { // WHY the order does not fit decides what the client can offer next: a balance that moved is // waited out or topped up, while another book's hold is somewhere the user can go and act. // ⚠ THE TEST IS «WOULD THIS ORDER HAVE FIT WITHOUT THAT HOLD», and the first edition of it was // a TAUTOLOGY: it compared this order's hold against a re-quote of the SAME order plus the // held amount, which is true whenever the amount is positive — so every refusal named another // book, including the ones that had nothing to do with it. The honest question compares // against the BALANCE the account would have, and it is the same question `Order` answers when // it decides whether to fill `blocked` at all. if held, amount, err := s.Store.CreditHeldBy(ctx, in.UserID, in.BookID); err == nil && held != "" && quote.Hold <= acct.Balance+amount { return pgstore.Run{}, &CreditHeldError{BookID: held} } return pgstore.Run{}, fmt.Errorf("%w: it reserves %s", ErrBalanceCannotCarry, quote.Hold.USD()) } offset, err := journalSize(book.Workdir) if err != nil { return pgstore.Run{}, err } started, err := s.Store.StartRun(ctx, pgstore.StartRunInput{ UserID: in.UserID, BookID: in.BookID, VerifyBank: in.VerifyBank, // How many chapters the order SPANS. For an order phrased in chapters that is what was bought, // and it is what the bar counts and what the wire publishes. For one phrased in CHARACTERS it // is only stored: the span overstates what was bought — a single unit of a four-unit chapter // spans `1` — so the wire publishes `ordered_units` instead and nulls this // (pgstore.runOrderedChapters). ⚠ It is never 0 for either shape (`max(chapters, 1)` in // QuoteUnits), and that is load-bearing: 0 is the RE-PASS's mark. Pinned by // runs.TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn and its chapter-shaped twin. // The ORDER itself lives on the book, in units. OrderedChapters: quote.Chapters, Ceiling: quote.Hold, Now: s.now(), Resnapshot: resnapshot, AcceptRebill: rebillConsent(resnapshot, quote.Hold), // ⚠ RECORDED FROM THE QUOTE THAT WAS ACTUALLY ACTED ON, not from the one the form showed. The // form is a read and the balance moves under it, so a buyer shown «funded» can be sold a run // that is not — and the passes degrade rather than halt, which makes the difference invisible // in the book. The run carries what it really got. BondFunded: quote.BondFunded, // ⚠ SET FOR EVERY ORDER PHRASED IN CHARACTERS — including one that happens to land on a chapter // boundary, see pricing.Quote.UnitShaped. It is what switches the run's bar to units: counted // in chapters, an order that stops inside one reads `0/N` for its whole life, which is the // state this very file refuses a book for at admission (PD-405). See migration 00033. OrderedUnits: unitShapedOrder(quote), Order: &order, }, offset, s.enqueue) if err != nil { return pgstore.Run{}, err } // Neither the book id nor the money: an INFO line names the run and nothing that identifies a // user's library or the size of their wallet (D39.84, PD-99). s.log().InfoContext(ctx, "run admitted", "run", started.ID, "ordered_chapters", quote.Chapters) return started.Run, nil } // rebillConsent is the sum a re-pinning run may spend on re-payment: its own hold, and never the // blanket form. // // FUNDED BY CONSTRUCTION, which is the property the adversarial pass K7 bought: a cap larger than // the hold lets a run burn a budget it does not have on re-billing, and a cap of «yes» lets a // projection grown past what the buyer saw be paid silently. Zero when nothing is being re-pinned. func rebillConsent(resnapshot bool, hold money.MicroUSD) money.MicroUSD { if !resnapshot { return 0 } return hold } // resolveOrder turns the three kinds of order into ONE quote and the BOUNDARY the book row stores. // // ⚠ The boundary is an IDENTITY and never a count: a book cut again re-numbers its chapters and // re-mints every unit id, so a stored count would silently become an order for different text // (migration 00033, ratified 05.09). An order for the WHOLE BOOK has no boundary at all, which is // the only honest way to say «all of it, however it is cut». func (s *Service) resolveOrder(ctx context.Context, in StartRequest, priced pgstore.PricedBook, pb pricing.Book, balance money.MicroUSD) (pricing.Quote, pgstore.BookOrder, error) { switch { case in.Chapters != nil: if !(ingest.Manifest{Structure: priced.Structure}).ChapterOrdersOffered() { return pricing.Quote{}, pgstore.BookOrder{}, fmt.Errorf("%w: its structure is %q", ErrChapterOrdersUnavailable, priced.Structure) } if *in.Chapters < 1 { return pricing.Quote{}, pgstore.BookOrder{}, fmt.Errorf("%w: %d chapters", ErrCeilingOutOfBounds, *in.Chapters) } q := s.Pricing.Quote(pb, balance, *in.Chapters) if q.ThroughChapterID == "" { // The order reached the end of the book, so it IS the whole book — recorded as such rather // than as a boundary, for the reason above. return q, pgstore.BookOrder{}, nil } return q, pgstore.BookOrder{ ThroughChapterID: q.ThroughChapterID, ThroughChapterNumber: q.ThroughChapter, }, nil case in.Characters != nil: rows, err := s.Store.RemainingUnits(ctx, in.UserID, in.BookID) if err != nil { return pricing.Quote{}, pgstore.BookOrder{}, err } units := make([]pricing.Unit, len(rows)) for i, u := range rows { units[i] = pricing.Unit{ID: u.ID, Expected: u.Expected, SourceChars: u.SourceChars} } n, ok := pricing.UnitsFor(units, *in.Characters) if !ok || n < 1 { return pricing.Quote{}, pgstore.BookOrder{}, fmt.Errorf("%w: %d characters buys no unit of this book", ErrCeilingOutOfBounds, *in.Characters) } if n >= len(units) { return s.Pricing.Quote(pb, balance, 0), pgstore.BookOrder{}, nil } // ⛔ PRICED FROM THE UNITS THEMSELVES, not from the chapters they fall in. Quoting a unit // order at the price of every chapter it touches is what made this order meaningless exactly // where it is the only one available: a book with no chapter structure is ONE chapter, so a // thousand characters of it reserved the whole book. The chapter span below goes into the ROW // and no further: nothing publishes it any more (pgstore.runOrderedChapters nulls the view for // a unit-shaped run, because the span overstates what was bought), and the bar counts units // because `UnitShaped` is set two lines down. What the stored span is still FOR is the re-pass // discriminator — `ceiling_chapters = 0` is the re-pass's own mark, and a zero written here // would make this order look like one to three internal readers. q := s.Pricing.QuoteUnits(pb, balance, units, n, chaptersSpanning(pb, n)) q.UnitShaped = true return q, pgstore.BookOrder{ThroughUnitID: units[n-1].ID}, nil default: // Neither handle touched: the whole book, and no boundary to store. return s.Pricing.Quote(pb, balance, 0), pgstore.BookOrder{}, nil } } // chaptersSpanning is how many remaining chapters a prefix of `units` units reaches into — at least // one, because a bar with a denominator of zero is not a bar. func chaptersSpanning(b pricing.Book, units int) int { seen, n := 0, 0 for _, c := range b.Remaining { n++ seen += c.Units if seen >= units { break } } return max(n, 1) } // readyToTranslate is the half of the book vocabulary a run may start from: everything a finished // intake can leave behind, plus every state a previous run can leave a parsed book in. // // Written as an allowlist and not as "not uploading, not parsing, not rejected": a status added // later is one this function has never thought about, and refusing it is the safe direction — the // user gets a 409 they can report, rather than an engine started against a book in a state nobody // designed for. func readyToTranslate(status string) bool { switch status { case "not_started", "translating", "awaiting_bank", "ready", "paused", "stopped", "failed": return true } return false } func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, runID string) error { if s.Queue == nil { return nil // no queue configured: the reconciler still picks the run up on its next sweep } return s.Queue.EnqueueRun(ctx, tx, runID) } // runnable is what this DEPLOYMENT cannot do, as opposed to what this run cannot do. Both refusals // are of the run and not of the service: an instance whose runner is misconfigured still serves // every read, and taking the library down with it would turn a partial outage into a total one // during exactly the operation that causes it, a binary upgrade. func (s *Service) runnable() error { if len(s.Cfg.MarkerArgv) == 0 { return ErrRunnerIncomplete } if len(s.Cfg.Ceiling) == 0 { return runner.ErrCeilingNotWired } return nil } // ceilingFor renders the engine's ceiling argument for an amount. func (s *Service) ceilingFor(amount money.MicroUSD) ([]string, error) { return s.Cfg.Ceiling.Args(amount) } // unitShapedOrder is the run's own volume when its order was phrased in CHARACTERS, and nil for every // other shape. Nil is the ordinary case and leaves the bar counted in chapters exactly as before. // // ⚠ The condition is the PHRASING (`q.UnitShaped`), not where the order landed — see // pricing.Quote.UnitShaped for why, and for the measurement that killed the older wording. func unitShapedOrder(q pricing.Quote) *int { if !q.UnitShaped || q.Units <= 0 { return nil } n := q.Units return &n }