470 lines
26 KiB
Go
470 lines
26 KiB
Go
package runs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"textmachine/platform/internal/ingest"
|
|
"textmachine/platform/internal/pgstore"
|
|
"textmachine/platform/internal/runner"
|
|
)
|
|
|
|
// bank.go: the platform half of the bank-correction door (D39.156 pack 2в) — the service behind
|
|
// `POST /books/{bookId}/bank/corrections`. The call is SYNCHRONOUS by design: the engine's verb is
|
|
// $0, exits rather than waits on the signing stop, and its 5000-decision cap was chosen so one call
|
|
// fits a caller's timeout (backend/internal/pipeline/bankdecisions.go, maxDecisions) — so the
|
|
// handler accepts the document, runs the verb, and answers its report as the receipt.
|
|
//
|
|
// What makes the synchronous form safe is one per-book rule, held HERE and shared with Start and
|
|
// Resume: a corrections call and the spawn of a fresh run must not interleave on one book. The
|
|
// engine's flock already makes the interleaving harmless — the loser refuses with class 12 — but a
|
|
// spawned `translate` that dies on the flock is a wasted attempt with money machinery around it.
|
|
//
|
|
// The rule also decides which WORD each refusal carries, and the split is exact: `run_in_flight`
|
|
// is answered from the platform's own run row (below, under the mutex), and ONLY from it; a class
|
|
// 12 from the verb itself is by construction NOT a run — Start and Resume wait on this same mutex,
|
|
// the reconciler only restarts rows the check below already sees — so it is a transient holder of
|
|
// the project (a boundary materialization reading the book, an operator's manual tmctl) and is
|
|
// answered «busy, retry later», never with a run that does not exist (PD-400, первая половина).
|
|
//
|
|
// ⚠ NAMED BOUNDARY OF v1: the mutex is in-process. A second platform replica over one store would
|
|
// not see this one's calls, and the serialization narrows to per-replica: the cost is bounded and
|
|
// is exactly the noise the mutex prevents — a translate spawned into the verb's flock dies with a
|
|
// wasted attempt, a sibling verb answers «retry later». The boundary stops holding the day a second
|
|
// replica is deployed (PD-400, вторая половина); the cure then is an arbiter in the store, not a
|
|
// bigger mutex.
|
|
|
|
// BankCorrectionsInput is one validated correction document, in seam vocabulary. The HANDLER owns
|
|
// the wire form and its validation; by this point the decisions are renderable as they stand.
|
|
type BankCorrectionsInput struct {
|
|
UserID string
|
|
BookID string
|
|
Preview bool
|
|
Decisions []ingest.BankDecision
|
|
}
|
|
|
|
// BankReceipt is the whole answer of an accepted call, still in the engine's vocabulary where the
|
|
// two overlap — the projection into contract words (`depth`, pointers) is the HTTP layer's.
|
|
type BankReceipt struct {
|
|
Preview bool
|
|
Changed bool
|
|
Depth string
|
|
Accepted []ingest.AcceptedDecision
|
|
PreexistingFaults int
|
|
// Signature is nil when no run has reached a signing stop yet — there is nothing to count
|
|
// against (the report's empty `map`).
|
|
Signature *ingest.SignatureState
|
|
}
|
|
|
|
// ErrBankRefused is the all-or-nothing refusal: the document was read and declined whole, nothing
|
|
// was written, and the USER re-decides. The per-decision reasons ride the error.
|
|
type ErrBankRefused struct {
|
|
Refusals []ingest.RejectedDecision
|
|
}
|
|
|
|
func (e *ErrBankRefused) Error() string {
|
|
return fmt.Sprintf("runs: the correction document was refused whole (%d reasons)", len(e.Refusals))
|
|
}
|
|
|
|
// ErrBankIncomplete — the document was ACCEPTED and did not land whole (the engine's class 15). The
|
|
// remedy is to re-send the SAME document: the retry converges on the engine's byte no-op.
|
|
var ErrBankIncomplete = errors.New("runs: the correction write did not complete; re-sending the same document converges")
|
|
|
|
// ErrBankUnavailable — the deployment cannot serve this call RIGHT NOW and the remedy is neither a
|
|
// re-decision nor a blind resend: an unmigrated project schema, a refusal class this build has no
|
|
// number for, a stop that landed mid-call. Temporary from the caller's seat; the operator has the
|
|
// log line.
|
|
var ErrBankUnavailable = errors.New("runs: the correction door cannot answer right now")
|
|
|
|
// ErrBankDocumentTooLarge — the RENDERED decision document is over the engine's byte ceiling
|
|
// (ingest.MaxDecisionsDocument). The canon's word for the size bound is 413 «split the document»,
|
|
// and it must be said before the verb is spawned: the engine's own copy of this cap answers as a
|
|
// refusal, which reads «re-decide».
|
|
var ErrBankDocumentTooLarge = errors.New("runs: the rendered decision document is over the engine's byte ceiling")
|
|
|
|
// ApplyBankCorrections runs one correction document against a book, synchronously.
|
|
func (s *Service) ApplyBankCorrections(ctx context.Context, in BankCorrectionsInput) (BankReceipt, error) {
|
|
if s.Cfg.EngineBinary == "" {
|
|
// Unreachable through the mounted route — the door is not mounted without an engine — and
|
|
// still answered rather than assumed: a read replica reached directly must not panic.
|
|
return BankReceipt{}, errors.New("runs: this deployment has no engine to apply corrections with")
|
|
}
|
|
// The budget covers the WAIT as well as the verb: it is set before the queue, so K callers piled
|
|
// on one book cost each of them at most one budget, not K of them — and a caller whose client or
|
|
// deadline is gone leaves the queue instead of holding a place in it to do work for a dead
|
|
// request (workflow finding, P9).
|
|
//
|
|
// ⚠ The budget is the sweep's own TM_PLATFORM_RUN_BUDGET knob, and the engine sized its
|
|
// 5000-decision cap against the SHIPPED 60 s — a maximum document measures ~11.3 s of
|
|
// uninterruptible fold (backend bankdecisions.go, maxDecisions). An operator lowering the knob
|
|
// toward that figure makes a legal maximum document permanently unservable: every retry answers
|
|
// 503 and the «split it» word never comes (PD-408 carries this as the knob's named cost).
|
|
ctx, cancel := context.WithTimeout(ctx, s.runBudget())
|
|
defer cancel()
|
|
// The book lock FIRST, the live-run check UNDER it: read before the lock, the answer could be
|
|
// a moment from wrong — a resume admitted while this call waited would spawn into the verb's
|
|
// flock. Serialized, either this call finishes before the spawn happens, or the run row exists
|
|
// and the refusal below names it truthfully.
|
|
unlock, err := s.lockBook(ctx, in.BookID)
|
|
if err != nil {
|
|
// The queue outlived the caller's budget or the caller itself: «busy, retry later» is the
|
|
// honest word — nothing was read, spawned or written.
|
|
s.log().InfoContext(ctx, "a correction call left the book queue before its turn", "err", err)
|
|
return BankReceipt{}, ErrBankUnavailable
|
|
}
|
|
defer unlock()
|
|
book, err := s.Store.ReadBookForRun(ctx, in.UserID, in.BookID)
|
|
if err != nil {
|
|
return BankReceipt{}, err
|
|
}
|
|
if !readyToTranslate(book.Status) {
|
|
// The same gate Start holds, for the same reason wearing this door's hat: a book still
|
|
// arriving or being cut has no configuration the verb could open — reaching the engine
|
|
// anyway came back as its config class, which the caller saw as a 500 about our own wiring,
|
|
// and the parse worker's transient hold on the project could even dress it as a run
|
|
// (refuter finding, P9). The remedy is «wait for the intake», which is what book_not_ready
|
|
// says and internal_error does not.
|
|
return BankReceipt{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status)
|
|
}
|
|
if book.HasLiveRun && !book.LiveRunAwaitingBank {
|
|
// The book is being translated (admitted, queued or running). A signing stop is NOT this —
|
|
// but its row closes one sweep LATER than its screen opens: the journal's bank_stop event
|
|
// moves the status to `awaiting_bank` while `finished_at` waits for the exit marker
|
|
// (reconcile.finish), so for that window the row is live and the flock is already free or
|
|
// about to be. Refusing run_in_flight there refused the very screen the platform had just
|
|
// announced (workflow finding, P9) — the door opens on it instead, and if the engine is
|
|
// still winding down, its flock answers class 12: an honest transient «retry later».
|
|
return BankReceipt{}, pgstore.ErrRunInFlight
|
|
}
|
|
doc, err := ingest.EncodeDecisions(in.BookID, in.Decisions)
|
|
if err != nil {
|
|
return BankReceipt{}, err
|
|
}
|
|
if len(doc) > ingest.MaxDecisionsDocument {
|
|
// The engine's byte ceiling, measured HERE on the RENDERED document — the only form the
|
|
// engine will see — so its cap is never reached and the canon's 413 word is kept: the
|
|
// engine's own copy answers as a refusal of the set, which reads «re-decide» (workflow
|
|
// finding, P9). With HTML escaping off the band above the wire cap is the envelope's few
|
|
// bytes, and this gate is what keeps that band from answering with the wrong remedy.
|
|
return BankReceipt{}, ErrBankDocumentTooLarge
|
|
}
|
|
path, cleanup, err := s.decisionsFile(doc)
|
|
if err != nil {
|
|
return BankReceipt{}, err
|
|
}
|
|
defer cleanup()
|
|
// ⛔ THE WRITE-AHEAD MARK, COMMITTED BEFORE THE VERB, and it is the only channel a death with no
|
|
// channel leaves open (unified backlog row 400). What this door does is run the verb and then
|
|
// stamp the fact from its own receipt; the stamp has ONE writer and no sweep, so a process that
|
|
// dies in between leaves the book looking untouched — and the next run is admitted without
|
|
// `--resnapshot` and dies on the engine's snapshot guard AFTER its hold was taken (PD-425). The
|
|
// store-error half of that window is already answered below («re-send the same document», which
|
|
// converges on the engine's byte no-op); this is the half where nobody is left to re-send.
|
|
//
|
|
// ⚠ A PREVIEW SETS NOTHING: it writes no files by construction, so a mark it left would cost a
|
|
// later run a --resnapshot for an act that never happened.
|
|
//
|
|
// ⚠ AND THE DOOR REFUSES WHEN THE MARK CANNOT BE WRITTEN, rather than proceeding without a
|
|
// backstop. The asymmetry is the same one that chose the mark's direction: refusing costs this
|
|
// caller a «retry later» on a document whose re-send converges, while proceeding risks a paid run
|
|
// that dies after the money moved. It is one statement against a database this process is already
|
|
// connected to, on a path that has already made several reads.
|
|
if !in.Preview {
|
|
if err := s.Store.MarkBankMovePending(ctx, in.BookID, s.now()); err != nil {
|
|
s.log().ErrorContext(ctx, "the correction was not started: its write-ahead mark could not be recorded", "err", err)
|
|
return BankReceipt{}, ErrBankUnavailable
|
|
}
|
|
}
|
|
out, err := s.Bank.BankApply(ctx, s.Cfg.EngineBinary, book.Workdir, path, in.Preview)
|
|
if err != nil {
|
|
s.log().ErrorContext(ctx, "the correction verb could not be run", "err", err)
|
|
if ctx.Err() != nil {
|
|
// The budget or the caller ended the call — and reaching THIS branch means the verb did
|
|
// NOT exit on its own (a SIGTERM the engine catches comes back as exit 5 through the
|
|
// verdict, with res.Exited true): the process was SIGKILLed after bankStopGrace, or
|
|
// never started. A kill can land between the verb's two renames — exactly the
|
|
// half-landed pair class 15 exists to report — but there is no report to carry that
|
|
// word, so the honest remainder is «retry later»: a re-send converges on the engine's
|
|
// byte no-op from either state (PD-407 keeps the imprecision).
|
|
return BankReceipt{}, ErrBankUnavailable
|
|
}
|
|
return BankReceipt{}, err
|
|
}
|
|
rec, err := s.bankVerdict(ctx, in, out)
|
|
if err == nil && !in.Preview && corrected(rec) {
|
|
// The fact «the bank moved» is stamped from the door's OWN receipt — the one thing that
|
|
// cannot be blind to what the door just did.
|
|
//
|
|
// ⚠ THE REASON THIS COMMENT USED TO GIVE IS DEAD, and it is dead by a LANDING and not by an
|
|
// argument (unified backlog row 234). It said asking the engine's status here was the first
|
|
// edition's mistake because «status projects the STORED memory», so right after an apply it
|
|
// would honestly answer «nothing moved» (adversarial pass, K1). `D39.170` retired that
|
|
// premise: `foldMemoryForRead` became the read path's FIRST answer and `projectStoredMemory`
|
|
// dropped to a fallback (`backend/internal/pipeline/status.go`, grep
|
|
// `IT IS NO LONGER THE READ PATH'S FIRST ANSWER`). The sibling carrier of the same dead
|
|
// premise was rewritten on 30.08 (`internal/ingest/resync.go`, register row `PD-427`); this
|
|
// was the second one, and it stood.
|
|
//
|
|
// THE DECISION IS UNCHANGED and now rests on what it should have rested on all along: a
|
|
// receipt is the door's OWN observation of its OWN act, and a status call is a second
|
|
// opinion about it from a process that has to be spawned, can fail, and costs seconds of CPU
|
|
// per call. Even a status that now answers correctly would make the fact depend on being
|
|
// able to ask — which is exactly the dependency this whole file is built to avoid.
|
|
//
|
|
// ⚠ On a context of its OWN, detached from the caller — the same rule as the intake's
|
|
// `writeCtx` (books/parse.go) and the idempotency receipt's `settleCtx`
|
|
// (httpapi/idempotency.go) — and here it is the difference between a fact and nothing.
|
|
//
|
|
// ⚠ The references above and below are by SYMBOL, not by line: this pack moved four of the six
|
|
// numbered anchors an earlier edition of this comment carried, which is the same rot the
|
|
// register's own anchors are graded for. Grep the name.
|
|
//
|
|
// Two mechanics make the request's context the wrong one to write on. The verb comes back
|
|
// CLEAN on an already-cancelled call: `runner.BankApply` returns (outcome, nil) whenever the
|
|
// process exited, and the engine's last look at its context is BEFORE its writes — so a
|
|
// correction whose files LANDED routinely arrives here with ctx already dead, either because
|
|
// the tab closed or because the verb ate the whole budget set at the top of this function.
|
|
// And the remedy this door then names — «re-send the same document» — is addressed to
|
|
// precisely the client that is gone. The fact has no second writer and no sweep (grep
|
|
// `bank_moved_at =` — one statement sets it, in `pgstore.RecordBankMove`), so on r.Context()
|
|
// it is lost FOREVER: `bank_moved_at` stays NULL, the next ordinary run is admitted with
|
|
// resnapshot=false (`runs.Start`, grep `if book.BankMoved`) and dies on the engine's snapshot
|
|
// guard AFTER its hold was taken (`pgstore.StartRun`, the hold is taken before anything is
|
|
// spawned). That is PD-425, and it is money.
|
|
//
|
|
// recordBudget rather than the intake's 30 s: this write runs UNDER the book lock taken at
|
|
// the top, so its tail is a delay the next caller of this book pays.
|
|
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordBudget)
|
|
defer cancel()
|
|
if ferr := s.Store.RecordBankMove(c, in.BookID); ferr != nil {
|
|
// The correction LANDED — the engine's files moved — but the fact did not reach the
|
|
// store, and the next run's consents are decided from it. «Re-send the same document»
|
|
// is the honest remedy and it CONVERGES: the engine answers the re-send with its byte
|
|
// no-op, whose receipt reads already_applied — which is exactly why corrected() accepts
|
|
// that state too, or a failed write here would never be retried into place.
|
|
s.log().ErrorContext(ctx, "a correction landed but the bank-move fact did not", "err", ferr)
|
|
return BankReceipt{}, ErrBankIncomplete
|
|
}
|
|
}
|
|
// ⚠ THE MARK IS RETIRED ONLY AGAINST A RECEIPT, never against the absence of one. Two outcomes
|
|
// PROVE that nothing landed: the verb's own all-or-nothing refusal (it read the document and
|
|
// declined it whole) and a clean report that neither changed anything nor re-found it already
|
|
// applied. Everything else keeps the mark, and deliberately: a killed verb can die between its
|
|
// own two renames — the half-landed pair class 15 exists to report — so «no report» may not be
|
|
// read as «no change». The price of keeping it is one harmless --resnapshot on the next run.
|
|
var refused *ErrBankRefused
|
|
if !in.Preview && ((err == nil && !corrected(rec)) || errors.As(err, &refused)) {
|
|
// Detached like the stamp below it and for the same reason: the commonest way to arrive here
|
|
// with a dead context is a client that left, and the mark's whole job is to outlive that.
|
|
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordBudget)
|
|
defer cancel()
|
|
if ferr := s.Store.ClearBankMovePending(c, in.BookID); ferr != nil {
|
|
// Not an error to the caller: nothing landed, so the state is honest either way — the
|
|
// leftover mark costs the next run one harmless --resnapshot and nothing else.
|
|
s.log().ErrorContext(ctx, "a correction that landed nothing left its write-ahead mark behind", "err", ferr)
|
|
}
|
|
}
|
|
if errors.As(err, &refused) {
|
|
// The engine's cap refusals echo the document's PATH — this platform's own temp file. File
|
|
// paths are server topology and do not cross the wire (the same rule that keeps the report's
|
|
// `files` off it); the reason survives, the address does not.
|
|
for i := range refused.Refusals {
|
|
refused.Refusals[i].Reason = strings.ReplaceAll(refused.Refusals[i].Reason, path, "the decision document")
|
|
}
|
|
}
|
|
return rec, err
|
|
}
|
|
|
|
// bankVerdict maps the verb's exit and report onto the door's answers. The mapping of each class is
|
|
// the contract's or argued in the zone journal (pack P9); what this function must never do is let
|
|
// two different remedies reach the caller as one word.
|
|
func (s *Service) bankVerdict(ctx context.Context, in BankCorrectionsInput, out runner.BankApplyOutcome) (BankReceipt, error) {
|
|
fail := func(what string) (BankReceipt, error) {
|
|
s.log().ErrorContext(ctx, "the correction verb answered outside its contract",
|
|
"what", what, "exit", out.ExitCode, "stderr", out.Stderr, "decode_err", out.DecodeErr)
|
|
return BankReceipt{}, fmt.Errorf("runs: bank-apply: %s (exit %d)", what, out.ExitCode)
|
|
}
|
|
switch out.ExitCode {
|
|
case ingest.ExitClean:
|
|
if !out.Decoded {
|
|
// Exit 0 always prints the report; a clean exit without one is not the engine.
|
|
return fail("a clean exit carried no report")
|
|
}
|
|
return s.bankReceipt(in, out.Report)
|
|
case ingest.ExitDecisionsRejected:
|
|
if !out.Decoded || len(out.Report.Rejected) == 0 {
|
|
// The DECISIONS class prints a report in every spelling, the caps included, and a refusal
|
|
// names its reasons — that is the class's own contract (pipeline.ApplyBankDecisions). A
|
|
// 409 whose `refusals` came out empty would violate the canon's own minItems.
|
|
return fail("a refusal carried no report or no reasons")
|
|
}
|
|
return BankReceipt{}, &ErrBankRefused{Refusals: out.Report.Rejected}
|
|
case ingest.ExitWriteIncomplete:
|
|
// The report says which file landed; that truth is server-side. What the caller needs is
|
|
// the remedy, and it is the same whether nothing landed or half did.
|
|
s.log().ErrorContext(ctx, "a correction write did not complete; the client re-sends the same document",
|
|
"stderr", out.Stderr)
|
|
return BankReceipt{}, ErrBankIncomplete
|
|
case ingest.ExitProjectLocked:
|
|
// NOT run_in_flight on THIS replica: a live run was excluded under the mutex before the verb
|
|
// was spawned (ApplyBankCorrections), and no path of this process admits one while it is held
|
|
// — so the holder is transient (a boundary materialization, an operator's manual tmctl, the
|
|
// stopped run's engine still winding down) and «busy, retry later» is honest. ⚠ Across the v1
|
|
// boundary above the word CAN be wrong: a sibling replica's Start sees its own mutex and a
|
|
// clean runs table, and its translate — a real, hours-long run — wins this flock; the honest
|
|
// word there would be run_in_flight (PD-400, вторая половина: the accepted risk INCLUDES this
|
|
// false word, not just a wasted attempt).
|
|
s.log().ErrorContext(ctx, "corrections met a held project with no live run: a transient holder of the book's flock",
|
|
"stderr", out.Stderr)
|
|
return BankReceipt{}, ErrBankUnavailable
|
|
case ingest.ExitSchemaMismatch:
|
|
// The book's project predates the deployed engine: `tmctl migrate` is owed (deploy order,
|
|
// row 174). An operator's condition, not the user's — and temporary, which 503 says and a
|
|
// 500 would not.
|
|
s.log().ErrorContext(ctx, "corrections refused: the book's project schema is not the engine's; it awaits tmctl migrate",
|
|
"stderr", out.Stderr)
|
|
return BankReceipt{}, ErrBankUnavailable
|
|
case ingest.ExitStopped:
|
|
// The verb was wound down before it wrote anything (its own SIGTERM contract) — a deploy
|
|
// restart from the caller's seat. Retrying later converges either way.
|
|
s.log().ErrorContext(ctx, "the correction verb was stopped mid-call", "stderr", out.Stderr)
|
|
return BankReceipt{}, ErrBankUnavailable
|
|
case ingest.ExitConfigInvalid, ingest.ExitSourceUnreadable:
|
|
// Named BEFORE the band catch below: these are known classes whose fault sits on THIS side
|
|
// of the seam — the platform renders both of the verb's inputs — so they are an internal
|
|
// failure, not a "retry later" that would never come true.
|
|
return fail("the verb refused its caller's own wiring")
|
|
}
|
|
if ingest.Refused(out.ExitCode) {
|
|
// A refusal class this build has no mapping for (19 included): nothing was spent or written,
|
|
// but WHICH remedy applies is exactly what an unknown number cannot say — so the answer is
|
|
// the operator's pair of hands, not a guessed one of the other two.
|
|
s.log().ErrorContext(ctx, "corrections met a refusal class this build cannot map",
|
|
"exit", out.ExitCode, "stderr", out.Stderr)
|
|
return BankReceipt{}, ErrBankUnavailable
|
|
}
|
|
// Exit 1, the config class (10 — this platform renders both of the verb's inputs, so a broken
|
|
// one is OUR wiring), 11, and anything else: the deployment is what needs fixing.
|
|
return fail("an exit outside the door's contract")
|
|
}
|
|
|
|
// bankReceipt shapes the report of an accepted call. The report's own preview echo is `mode`; it is
|
|
// cross-checked against what was asked rather than trusted, because the receipt of an APPLY served
|
|
// for a preview would tell a user their correction landed when nothing did.
|
|
func (s *Service) bankReceipt(in BankCorrectionsInput, rep ingest.BankReport) (BankReceipt, error) {
|
|
wantMode := "apply"
|
|
if in.Preview {
|
|
wantMode = "projection"
|
|
}
|
|
if rep.Mode != wantMode {
|
|
return BankReceipt{}, fmt.Errorf("runs: bank-apply answered mode %q to a %q call", rep.Mode, wantMode)
|
|
}
|
|
out := BankReceipt{
|
|
Preview: in.Preview, Changed: rep.Changed, Depth: rep.Depth,
|
|
Accepted: rep.Accepted,
|
|
PreexistingFaults: len(rep.PreexistingProblems),
|
|
}
|
|
if rep.Signature.Map != "" {
|
|
sig := rep.Signature
|
|
out.Signature = &sig
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// corrected answers whether this receipt proves the bank carries the document: a write that
|
|
// changed files, or a retry whose every accepted state reads already_applied — the byte no-op of
|
|
// a document that landed before. Both prove the move; a no-op that neither changed nor re-found
|
|
// anything proves none.
|
|
func corrected(rec BankReceipt) bool {
|
|
if rec.Changed {
|
|
return true
|
|
}
|
|
for _, a := range rec.Accepted {
|
|
if a.State == "already_applied" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SweepCorrectionScratch removes decision documents an unclean death left behind: cleanup rides a
|
|
// defer and dies with the process (SIGKILL, OOM, a deploy's expired grace), nothing else deletes
|
|
// by this mask, and each orphan carries up to a megabyte of a user's own corrections sitting in
|
|
// the state directory indefinitely (workflow finding, P9). Called once at boot, before the door
|
|
// serves — a file present THEN belongs to no live call by definition.
|
|
func (s *Service) SweepCorrectionScratch() {
|
|
stale, err := filepath.Glob(filepath.Join(s.Cfg.StateDir, "bank-corrections-*.json"))
|
|
if err != nil || len(stale) == 0 {
|
|
return
|
|
}
|
|
for _, f := range stale {
|
|
_ = os.Remove(f)
|
|
}
|
|
s.log().Info("swept correction documents an earlier process left behind", "count", len(stale))
|
|
}
|
|
|
|
// decisionsFile puts the rendered document where the engine can read it — the platform's OWN state
|
|
// directory, never the book's (D39.110: the engine owns that one).
|
|
func (s *Service) decisionsFile(doc []byte) (string, func(), error) {
|
|
if err := os.MkdirAll(s.Cfg.StateDir, 0o750); err != nil {
|
|
return "", nil, fmt.Errorf("runs: state directory: %w", err)
|
|
}
|
|
f, err := os.CreateTemp(s.Cfg.StateDir, "bank-corrections-*.json")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("runs: write the decision document: %w", err)
|
|
}
|
|
cleanup := func() { _ = os.Remove(f.Name()) }
|
|
if _, err := f.Write(doc); err != nil {
|
|
_ = f.Close()
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("runs: write the decision document: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("runs: write the decision document: %w", err)
|
|
}
|
|
return f.Name(), cleanup, nil
|
|
}
|
|
|
|
// lockBook serializes this book's admissions, resumes and corrections against each other, and
|
|
// watches the caller's context while it WAITS: the critical sections are one engine verb or one
|
|
// database transaction, but the WAIT for them is only bounded when a caller whose deadline or
|
|
// client is gone can leave the queue — a sync.Mutex cannot be waited on under a context, and with
|
|
// one the K-th caller held its place for K budgets and then did the work for a dead request
|
|
// (workflow finding, P9). A one-slot channel is that same mutex, waitable.
|
|
func (s *Service) lockBook(ctx context.Context, bookID string) (unlock func(), err error) {
|
|
s.booksMu.Lock()
|
|
if s.books == nil {
|
|
s.books = make(map[string]*bookLock)
|
|
}
|
|
l := s.books[bookID]
|
|
if l == nil {
|
|
l = &bookLock{slot: make(chan struct{}, 1)}
|
|
s.books[bookID] = l
|
|
}
|
|
l.refs++
|
|
s.booksMu.Unlock()
|
|
release := func() {
|
|
s.booksMu.Lock()
|
|
if l.refs--; l.refs == 0 {
|
|
delete(s.books, bookID) // books are unbounded over a process's life; idle locks are not kept
|
|
}
|
|
s.booksMu.Unlock()
|
|
}
|
|
select {
|
|
case l.slot <- struct{}{}:
|
|
return func() { <-l.slot; release() }, nil
|
|
case <-ctx.Done():
|
|
release()
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
type bookLock struct {
|
|
slot chan struct{} // capacity 1: the token in it IS the lock
|
|
refs int
|
|
}
|