textmachine/platform/internal/runs/reconcile.go

456 lines
21 KiB
Go

package runs
import (
"context"
"errors"
"fmt"
"path/filepath"
"time"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/runner"
)
// spawnGrace is how long after an attempt was admitted the reconciler still believes systemd simply
// has not got to it. Under it, "no unit and no marker" means "not started yet"; over it, the same
// two facts mean the unit died without being able to say so — which is what a reboot looks like,
// since a transient unit does not survive one and its ExecStopPost never runs.
const spawnGrace = 60 * time.Second
// Sweep reconciles every run the database believes is live, then settles the money of every run
// that finished without it.
//
// It runs on a ticker AND at boot, and the boot pass is not a special case: the same reading of the
// same three sources — Postgres, the book's journal, the exit marker — restarts what a reboot
// interrupted (unified backlog row 138). systemd is asked only "is this unit still there", and its
// answer is evidence rather than truth (research/25 §Опс).
//
// One run's failure never stops the sweep: these are independent runs belonging to independent
// accounts, and a book whose directory an operator moved must not freeze everyone else's progress.
func (s *Service) Sweep(ctx context.Context) error {
live, err := s.Store.ListLiveRuns(ctx)
if err != nil {
return err
}
for _, l := range live {
if err := ctx.Err(); err != nil {
return err
}
if err := s.reconcile(ctx, l); err != nil {
s.log().ErrorContext(ctx, "run could not be reconciled", "run", l.RunID, "err", err)
}
}
unsettled, err := s.Store.UnsettledRuns(ctx)
if err != nil {
return err
}
for _, u := range unsettled {
if err := ctx.Err(); err != nil {
return err
}
if err := s.settle(ctx, u); err != nil {
s.log().ErrorContext(ctx, "run could not be settled", "run", u.RunID, "err", err)
}
}
return nil
}
func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error {
// The cursor this call MOVED, not the one the sweep's snapshot was taken with. Everything below
// that asks "has the stream said anything" has to ask about now: the snapshot is read before the
// journal is drained, so a run whose first events arrive during this very sweep still looks
// silent in it — and the repair channel would then be woken for a run that had just spoken.
// Measured end to end, not reasoned about: a live run showed "edit 10/10" from a status report
// seconds after its journal said "edit 0/10".
// ⚠ A journal that cannot be read stops the PROJECTION and nothing else. It used to abort the
// whole reconcile before the exit marker was even looked at, so one malformed line left a run
// "translating" forever with its hold reserved — the engine long gone, the marker on disk, and
// every sweep failing at the same byte. The lifecycle is decided from the marker and from
// systemd; the journal only decides how fresh the numbers are.
seq, drainErr := s.drainJournal(ctx, l)
l.Position.LastSeq = max(l.Position.LastSeq, seq)
if drainErr != nil {
s.log().ErrorContext(ctx, "journal could not be materialized; the run's lifecycle continues without it",
"run", l.RunID, "err", drainErr)
}
marker, err := runner.ReadMarker(s.markerPath(l.RunID, l.AttemptNo))
switch {
case err == nil:
return s.finish(ctx, l, marker)
case !errors.Is(err, runner.ErrNoMarker):
return err
}
if l.UnitName == "" {
// Admitted and never spawned: the platform stopped between the transaction and the unit, or
// the queue entry was lost. The reconciler is the backstop for both.
return s.spawnAttempt(ctx, l)
}
alive, err := s.Runner.Alive(ctx, l.UnitName)
if err != nil {
// The bus did not answer. "I could not ask" must never be read as "the run is gone": that
// mistake restarts a live engine against its own project lock.
return err
}
if alive {
return s.maybeResync(ctx, l)
}
if s.now().Sub(l.AttemptStartedAt) < spawnGrace {
return nil // systemd has not started it yet
}
return s.restart(ctx, l)
}
// drainJournal applies whatever the engine has written since the cursor, and returns where the
// cursor now stands.
func (s *Service) drainJournal(ctx context.Context, l pgstore.LiveRun) (int64, error) {
if l.Quarantined {
return l.Position.LastSeq, nil
}
path := filepath.Join(l.Workdir, ingest.JournalFile)
sink := s.Store.NewRunSink(l.AttemptID, l.RunID, l.BookID)
from := ingest.Position{Offset: l.Position.Offset, LastSeq: l.Position.LastSeq, LastHash: l.Position.LastHash}
pos, _, err := ingest.Tail(ctx, path, l.EngineRunID, from, sink)
switch {
case errors.Is(err, ingest.ErrNoJournal):
// The emitter is unified backlog row 103 and does not exist yet, so this is the ordinary case
// today. The tailer is built against the vocabulary and waits.
return l.Position.LastSeq, nil
case err != nil && !quarantines(err):
// A moment we could not read it, not a stream we cannot read. Returned so the sweep logs it and
// meets the same bytes again next pass.
return l.Position.LastSeq, err
case err != nil:
// The RUN is left alone: it is spending money the account reserved, and our inability to read
// its journal is not a reason to throw that away. Freshness falls back to the resync channel.
s.log().ErrorContext(ctx, "journal cannot be materialized; falling back to resync",
"run", l.RunID, "err", err)
return l.Position.LastSeq, s.Store.Quarantine(ctx, l.AttemptID, err.Error())
}
if pos.Offset > l.Position.Offset {
// Lines that were read and NOT applied — duplicates the cursor already covers — still move
// the byte hint. Without this they are re-read on every sweep, forever.
return pos.LastSeq, s.Store.SaveCursor(ctx, l.AttemptID, pgstore.Position{Offset: pos.Offset})
}
return pos.LastSeq, nil
}
// quarantines decides what a failure to materialize the journal MEANS: a stream this platform cannot
// read, or a moment in which it could not read one.
//
// That distinction is the whole decision, and getting it wrong is expensive in both directions. A
// gap, a changed payload, a malformed line, a line past the buffer: none is repaired by reading the
// same bytes again, and retrying them forever is what wedged a run — so those stop the projection.
// Our own shutdown and a lock Postgres broke are the opposite: the very next sweep reads exactly the
// same bytes successfully, and stopping the projection over one blinds a live, paying run for good.
func quarantines(err error) bool {
switch {
case err == nil:
return false
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return false
case pgstore.IsTransient(err):
return false
default:
return true
}
}
// maybeResync refreshes a live run from `tmctl status --json`, at most once every ResyncEvery.
//
// ⚠ What it produces is HONESTLY STALE, and by an amount the interval names: the run moves
// continuously and this reads it every few minutes. It exists because the event emitter does not
// (row 103); when it does, this becomes what it was designed to be — a repair path, not the only
// source of progress.
func (s *Service) maybeResync(ctx context.Context, l pgstore.LiveRun) error {
// The stream, when there is one, is the FRESHER source and the free one. A status call costs
// seconds of CPU on the engine's side, every time, and can only report what the journal has
// already said — so the repair channel runs where there is nothing to repair from: an attempt
// whose cursor has never moved, or one whose materialization was quarantined and for which this
// is now the only source.
//
// ⚠ This gate used to carry a second job that it no longer has to: before the engine reported the
// per-wave split (row 99, landed D39.122) a resync could only fold ONE aggregate over the two
// counters the stream keeps apart, which is how a live run showed "edit 10/10" seconds after its
// journal said "edit 0/10". ApplyStatus now materializes the same four counters as the stream.
if l.Position.LastSeq > 0 && !l.Quarantined {
return nil
}
if !s.dueForResync(l.RunID) {
return nil
}
now := s.now()
rep, err := s.Engine.Status(ctx, s.engineBinary(l), l.Workdir)
if err != nil {
// A status call that fails is not a run that failed. It is logged and retried on the next
// interval; the read model keeps the last figures it had.
s.log().WarnContext(ctx, "resync failed", "run", l.RunID, "err", err)
return nil
}
return s.Store.ApplyStatus(ctx, l.RunID, l.BookID, rep, now)
}
// dueForResync is the rate limit, and it is the whole of what makes the resync affordable: the
// sweep runs every few seconds and each status call costs seconds of CPU. It records the decision as
// it makes it, so two sweeps cannot both conclude "due".
//
// In memory on purpose: this is a rate limit, not a fact — losing it on restart costs one extra
// status call, whereas persisting it would put a row write on a path that exists to avoid work.
func (s *Service) dueForResync(runID string) bool {
if s.Cfg.ResyncEvery <= 0 || s.Engine == nil {
return false
}
now := s.now()
if s.resynced == nil {
s.resynced = map[string]time.Time{}
}
if last, ok := s.resynced[runID]; ok && now.Sub(last) < s.Cfg.ResyncEvery {
return false
}
s.resynced[runID] = now
return true
}
// finish closes a run whose unit has ended.
func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker) error {
delete(s.resynced, l.RunID) // a finished run keeps no rate-limit entry: the map is per process
status, exit := outcome(l, m)
if err := s.Store.FinishRun(ctx, l.RunID, l.AttemptID, status, m.Result, exit, s.now()); err != nil {
return err
}
s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", status, "result", m.Result)
// Settling immediately rather than waiting for the next sweep: the hold is the account's money
// and every second it stays reserved is a second the user cannot start another book.
return s.settle(ctx, l)
}
// outcome maps what systemd saw onto the product status of the run.
//
// The engine's exit contract is the input for a process that exited on its own (0 clean · 2
// completed with flagged units · 3 the deliberate bank-signing stop · 1 everything else); a process
// that did NOT exit on its own has no exit code to map, and $SERVICE_RESULT is what tells a stop we
// asked for from a kill we did not.
//
// ⚠ Named limit, not an oversight: a CEILING stop is exit 1 today, indistinguishable from an infra
// failure, because the engine reports it as an error and the ceiling EVENT does not exist yet
// (unified backlog row 103 — verified in the engine's own code, stagerun.go, where the ceiling
// verdict returns errReserveCeiling). The contract requires a ceiling stop to be `paused` and never
// `failed`, so until the emitter lands the only ceiling stop this platform can report correctly is
// one it heard about on the stream — which is exactly the branch below that reads paused_reason.
func outcome(l pgstore.LiveRun, m runner.Marker) (status string, exitCode *int) {
if l.PausedReason != "" {
return "paused", nil
}
code, exited := m.Exited()
if !exited {
if m.Result == "success" {
return "stopped", nil // asked to stop, and it did
}
return "failed", nil
}
switch code {
case 0, 2:
// 2 is "completed with flagged units": a finished translation whose notes carry the flags.
return "ready", &code
case 3:
// The bank-signing stop. The ATTEMPT is over — the engine exits — and so is this run row;
// the work is resumable and resuming it starts a new run, because the contract's resume
// handle is not part of this pack.
return "awaiting_bank", &code
default:
return "failed", &code
}
}
// settle resolves the money of an attempt whose process is gone.
//
// The figure is the engine's OWN committed spend, read through `tmctl status --json` — the ratified
// repair channel — after the process has exited, so there is no lock to contend with and no
// inference from a stream that may have been truncated. If it cannot be read the reservation stays
// OPEN and the next sweep tries again: a hold that is still reserved is visible and recoverable,
// whereas a settlement against a guessed number is neither.
//
// ⚠ What this deliberately does NOT do is the escrow half of research/25 — write-ahead intent, the
// `uncertain` state, `closing` until the terminal seq. That is unified backlog row 136 and its own
// prompt; building half of it here would put a second, weaker answer next to the one being designed.
func (s *Service) settle(ctx context.Context, l pgstore.LiveRun) error {
if s.Engine == nil {
return nil
}
key := pgstore.ReservationKey(l.RunID, l.AttemptNo)
rep, err := s.Engine.Status(ctx, s.engineBinary(l), l.Workdir)
if err != nil {
s.log().WarnContext(ctx, "settlement deferred: the engine's committed spend could not be read",
"run", l.RunID, "err", err)
return nil
}
if rep.Spend == nil {
// Absent is not zero (PD-40): a settlement computed from a missing figure would release the
// whole hold and charge nothing.
s.log().WarnContext(ctx, "settlement deferred: the status report carries no committed spend",
"run", l.RunID)
return nil
}
// What the book's counter says NOW, bounded by what it said when a later attempt of the same book
// started. Settlement is allowed to defer and a deferred one can be overtaken: the run is finished,
// so nothing stops the account from starting another on the same book, and the counter this reads
// is the BOOK's lifetime total. Without the bound the deferred settlement of the first run pays for
// the second one's work as well, and the second then pays for it again.
bound, err := s.Store.SpendBound(ctx, l.BookID, l.AttemptID)
if err != nil {
return err
}
committed := *rep.Spend
if bound != nil && *bound < committed {
committed = *bound
}
if l.SpendBaseline != nil && committed < *l.SpendBaseline {
// The book's meter went BACKWARDS across this attempt, which no run produces: it is a project
// database that was replaced or restored. attemptSpend clamps to zero rather than paying an
// account for it, and this says so out loud — a settlement of nothing is not something to
// discover from a balance. WARN and not INFO because the subject is money; the figures
// themselves stay out of the line (D39.84).
s.log().WarnContext(ctx, "the book's meter reads below this attempt's own baseline; it is settled at nothing",
"run", l.RunID, "attempt", l.AttemptNo)
}
spent, err := attemptSpend(l, committed)
if err != nil {
if l.UnitName == "" {
// No baseline because there was no spawn: the attempt was admitted and never started, so it
// cost nothing and its hold comes back WHOLE. Without this the money of a run that never ran
// is reserved forever.
//
// "Never started" is re-checked against the ROW, not against this snapshot: the sweep read
// its list before working through it, and a run can be spawned, spend and exit inside that
// window. Measured: the hold of an attempt that spent $0.50 came back in full.
switch err := s.Store.ReleaseUnspawned(ctx, key, l.AttemptID, s.now()); {
case errors.Is(err, pgstore.ErrAttemptSpawned):
s.log().InfoContext(ctx, "settlement deferred: the attempt was spawned after this sweep read it",
"run", l.RunID, "attempt", l.AttemptNo)
return nil // the next sweep sees the unit and settles against its baseline
case err != nil && !errors.Is(err, pgstore.ErrNoReservation):
return err
}
return s.Store.MarkSettled(ctx, l.RunID, s.now())
}
// Spawned, but with no baseline — an attempt from before this column existed. Charging the
// book's lifetime total would bill every earlier run again and charging zero would give this
// one away, so it is left open and said out loud.
s.log().ErrorContext(ctx, "settlement withheld: this attempt ran without a spend baseline",
"run", l.RunID, "attempt", l.AttemptNo)
return nil
}
if err := s.Store.Settle(ctx, key, spent, s.now()); err != nil {
if errors.Is(err, pgstore.ErrNoReservation) {
// Already closed by an earlier pass. Settling is idempotent by refusal, not by repetition.
return s.Store.MarkSettled(ctx, l.RunID, s.now())
}
return err
}
return s.Store.MarkSettled(ctx, l.RunID, s.now())
}
// restart brings back a run whose unit vanished without a word — the case a reboot produces, since
// transient units do not survive one (research/25 §Форма, unified backlog row 138).
//
// The money is closed on the old attempt and reopened on the new one rather than carried over: the
// old hold was taken for a process that is gone, and what the account still owes for it is the
// engine's committed figure, not the ceiling. What remains of the run's budget is what the new
// attempt gets, so a restart cannot spend the run's ceiling twice.
//
// The LIMIT the resumed process is given is not that remainder: spawnAttempt reads the engine's
// meter again and hands it committed + the remainder, because the flag caps the book's cumulative
// spend and the interrupted attempt moved it (D39.122, and meter.bookCap for why the reserved figure
// is deliberately not in that sum).
func (s *Service) restart(ctx context.Context, l pgstore.LiveRun) error {
if err := s.settle(ctx, l); err != nil {
return err
}
// Settling is allowed to DEFER — the engine's figure may not be readable — and a deferral must not
// become a second reservation. Asked rather than assumed: proceeding on an unsettled attempt holds
// the ceiling twice and strands the first hold where no later sweep looks for it.
open, err := s.Store.AttemptReservationOpen(ctx, l.RunID, l.AttemptNo)
if err != nil {
return err
}
if open {
s.log().WarnContext(ctx, "restart deferred: the interrupted attempt is not settled yet",
"run", l.RunID, "attempt", l.AttemptNo)
return nil
}
budget := s.Pricing.Ceiling(l.CeilingChapters)
spent, err := s.Store.RunSpent(ctx, l.RunID)
if err != nil {
return err
}
remaining := budget - spent
if remaining <= 0 {
// `paused` WITH its reason: the contract describes PausedReason as the reason a run is paused,
// and a paused run with a null reason gives the screen nothing to say. PauseRun is the path
// that sets it; FinishRun does not.
s.log().InfoContext(ctx, "interrupted run has no budget left", "run", l.RunID)
return s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now())
}
offset, err := journalSize(l.Workdir)
if err != nil {
return err
}
// Nil = inherit the pinned build. A different one only when an operator asked for it (row 139).
var version *string
if s.Cfg.AllowEngineVersionChange && s.Cfg.EngineBinary != "" {
version = &s.Cfg.EngineBinary
}
next, err := s.Store.RestartRun(ctx, pgstore.RestartInput{
RunID: l.RunID,
AttemptID: l.AttemptID,
UserID: l.UserID,
BookID: l.BookID,
Ceiling: remaining,
Offset: offset,
EngineBinary: version,
Now: s.now(),
})
if errors.Is(err, pgstore.ErrInsufficientCredit) {
// The balance went elsewhere while this run was down. Honest state, not a failure: the run is
// paused for the reason the contract has a word for.
s.log().InfoContext(ctx, "interrupted run cannot be resumed on the current balance", "run", l.RunID)
return s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now())
}
if err != nil {
return err
}
s.log().InfoContext(ctx, "interrupted run restarted", "run", l.RunID, "attempt", next.AttemptNo)
return s.spawnAttempt(ctx, next)
}
// Stop asks a live run to shut down. The engine stops gracefully on SIGTERM and finishes the chunk
// it has already paid for; the reconciler turns the exit into a status.
func (s *Service) Stop(ctx context.Context, l pgstore.LiveRun) error {
if l.UnitName == "" {
return fmt.Errorf("runs: run %s has no unit to stop", l.RunID)
}
return s.Runner.Stop(ctx, l.UnitName)
}
// attemptSpend turns the engine's figure into what THIS attempt cost.
//
// ⚠ The engine's `committed_usd` is a lifetime total for the BOOK — literally
// `SELECT COALESCE(SUM(committed_usd),0) FROM spend WHERE book_id = ?`
// (backend/internal/store/ledger.go at HEAD, reached through pipeline/status.go) — across every run
// and every day. Settling with it verbatim charges an account for all of the book's earlier runs
// again, every time; the overcharge is bounded by the hold, and the ledger then reads "capped at the
// hold", which looks like an engine overspend rather than the platform's own arithmetic. So each
// attempt records where the meter stood when it started and owes the DIFFERENCE.
//
// Clamped at zero: a total that went DOWN is not a refund, it is a book whose project database was
// replaced, and paying an account for that is not something this code may decide.
func attemptSpend(l pgstore.LiveRun, committed money.MicroUSD) (money.MicroUSD, error) {
if l.SpendBaseline == nil {
return 0, errors.New("runs: the attempt has no spend baseline")
}
if committed < *l.SpendBaseline {
return 0, nil
}
return committed - *l.SpendBaseline, nil
}