640 lines
32 KiB
Go
640 lines
32 KiB
Go
// 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 <id>`.
|
||
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 <id>`, 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,
|
||
// The RUN's own bar is still counted in chapters (canon §Progress), so it carries how many the
|
||
// order spans; 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 ONLY FOR AN ORDER THAT DOES NOT CLOSE WHOLE CHAPTERS. It is what switches the run's bar
|
||
// to units — see migration 00033: counted in chapters, such a run reads `0/N` for its whole
|
||
// life, which is the state this very file refuses a book for at admission (PD-405).
|
||
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 is carried only
|
||
// for the RUN's bar, which is still counted in chapters (canon §Progress), and it is the
|
||
// coarser of the two figures — a character order can stop inside a chapter.
|
||
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 does not close whole chapters, and nil when
|
||
// it does. Nil is the ordinary shape and leaves the bar counted in chapters exactly as before.
|
||
func unitShapedOrder(q pricing.Quote) *int {
|
||
if !q.UnitShaped || q.Units <= 0 {
|
||
return nil
|
||
}
|
||
n := q.Units
|
||
return &n
|
||
}
|