741 lines
36 KiB
Go
741 lines
36 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"textmachine/platform/internal/money"
|
|
)
|
|
|
|
// Tx is a transaction of this store. Exported so a caller can join its own write to one — the queue
|
|
// does, because a run row and its queue entry must be written together — without every package in
|
|
// the zone importing pgx to name the type.
|
|
type Tx = pgx.Tx
|
|
|
|
var (
|
|
// ErrRunInFlight is a second live run on one book. The database refuses it (the partial unique
|
|
// index runs_one_live_per_book) rather than the API explaining it: the engine holds an EXCLUSIVE
|
|
// lock on the project file, so two live runs is not a state anything downstream can represent.
|
|
ErrRunInFlight = errors.New("pgstore: the book already has a live run")
|
|
// ErrNoRun is a run that does not exist or does not belong to the caller.
|
|
ErrNoRun = errors.New("pgstore: no such run")
|
|
)
|
|
|
|
// StartRunInput is one accepted run request.
|
|
type StartRunInput struct {
|
|
UserID string
|
|
BookID string
|
|
VerifyBank bool
|
|
CeilingChapters int
|
|
// Ceiling is what those chapters are worth and what is HELD before anything is spawned. The hold
|
|
// and the ceiling handed to the engine are the same number by construction: the hold makes the
|
|
// credit unavailable to any other run, and the engine stops itself there, so an overspend is
|
|
// impossible even while the platform is blind (D39.100).
|
|
Ceiling money.MicroUSD
|
|
Now time.Time
|
|
}
|
|
|
|
// StartedRun is what the caller needs after a successful start.
|
|
type StartedRun struct {
|
|
Run
|
|
AttemptID int64
|
|
// JournalOffset is the size of the book's journal at the moment this attempt was admitted. The
|
|
// journal is per BOOK and append-only, so an attempt's own lines begin after everything already
|
|
// in it; without this the tailer would adopt the previous attempt's handshake.
|
|
JournalOffset int64
|
|
}
|
|
|
|
// StartRun admits a run: the run row, its first attempt, the hold and the queue entry, all in ONE
|
|
// transaction.
|
|
//
|
|
// One transaction because each of them alone is a way to lose money or work. A hold without a run
|
|
// row is credit reserved for nothing; a run row without a hold is a run that spends credit nobody
|
|
// set aside; a queue entry without either is a worker spawning an engine against a book whose
|
|
// bookkeeping does not exist. research/25 names the first of those explicitly ("a hold can leak
|
|
// before the directory exists"), and the answer is not a compensating sweep but not splitting the
|
|
// write in the first place.
|
|
//
|
|
// enqueue is handed the transaction so the queue's own insert joins it. It is a callback rather than
|
|
// a second call because River owns its SQL and this package owns its own: neither reaches into the
|
|
// other, and the atomicity is still real.
|
|
func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset int64, enqueue func(context.Context, Tx, string) error) (StartedRun, error) {
|
|
if in.CeilingChapters <= 0 {
|
|
return StartedRun{}, fmt.Errorf("pgstore: a run needs a positive chapter ceiling, got %d", in.CeilingChapters)
|
|
}
|
|
out := StartedRun{JournalOffset: journalOffset}
|
|
err := s.inTx(ctx, func(tx pgx.Tx) error {
|
|
// The book first, before the hold — see lockBook for why the order is global and not local.
|
|
// Here it also makes the race between two admissions of one book a queue instead of a
|
|
// collision: the loser still gets ErrRunInFlight, it just gets it after waiting.
|
|
if err := lockBook(ctx, tx, in.BookID); err != nil {
|
|
return err
|
|
}
|
|
runID := newID("run")
|
|
const insertRun = `
|
|
insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at, revision)
|
|
select $1, $2, 'translating', $3, $4, $5, b.revision + 1
|
|
from books b where b.id = $2 and b.owner_id = $6
|
|
returning id, book_id, revision, status, verify_bank, ceiling_chapters,
|
|
coalesce(paused_reason, ''), started_at, finished_at`
|
|
err := tx.QueryRow(ctx, insertRun, runID, in.BookID, in.VerifyBank, in.CeilingChapters, in.Now, in.UserID).
|
|
Scan(&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, &out.CeilingChapters,
|
|
&out.PausedReason, &out.StartedAt, &out.FinishedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNoBook // the book is missing, or it is not this account's
|
|
}
|
|
if isUnique(err, "runs_one_live_per_book") {
|
|
return ErrRunInFlight
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: insert run: %w", err)
|
|
}
|
|
if err := tx.QueryRow(ctx, `
|
|
insert into run_attempts (run_id, attempt_no, started_at, last_offset)
|
|
values ($1, 1, $2, $3) returning id`, out.ID, in.Now, journalOffset).Scan(&out.AttemptID); err != nil {
|
|
return fmt.Errorf("pgstore: insert attempt: %w", err)
|
|
}
|
|
// The hold is taken here, BEFORE anything is spawned, and it is the enforcement half of the
|
|
// money design rather than an accounting note.
|
|
if err := holdTx(ctx, tx, in.UserID, in.BookID, engineRunKey(out.ID, 1), in.Ceiling, in.Now); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
update books set status = 'translating',
|
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, in.BookID); err != nil {
|
|
return fmt.Errorf("pgstore: mark book translating: %w", err)
|
|
}
|
|
if enqueue != nil {
|
|
return enqueue(ctx, tx, out.ID)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return StartedRun{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// engineRunKey is the reservation key of an attempt.
|
|
//
|
|
// ⚠ It is the PLATFORM's attempt identity, not the engine's `engine_run_id`: the engine mints its
|
|
// own id per invocation and the platform only learns it from the handshake, which arrives after the
|
|
// money has already been reserved. Reservations are keyed by attempt for exactly that reason — the
|
|
// hold has to exist before there is anything to key it by on the engine's side.
|
|
func engineRunKey(runID string, attempt int) string {
|
|
return fmt.Sprintf("%s#%d", runID, attempt)
|
|
}
|
|
|
|
func isUnique(err error, constraint string) bool {
|
|
var pg *pgconn.PgError
|
|
return errors.As(err, &pg) && pg.Code == "23505" && pg.ConstraintName == constraint
|
|
}
|
|
|
|
// LiveRun is a run the reconciler has to make a decision about.
|
|
type LiveRun struct {
|
|
RunID string
|
|
BookID string
|
|
UserID string
|
|
Workdir string
|
|
AttemptID int64
|
|
AttemptNo int
|
|
UnitName string
|
|
EngineRunID string
|
|
Position Position
|
|
Quarantined bool
|
|
VerifyBank bool
|
|
// CeilingChapters is the run's whole budget, in the unit the user chose; Ceiling is what THIS
|
|
// attempt was allowed to spend. They differ after a restart, which gets what is left.
|
|
CeilingChapters int
|
|
Ceiling money.MicroUSD
|
|
// EngineBinary is the VERSIONED path this attempt was pinned to (unified backlog row 139). Read
|
|
// back and USED — for the resume and for the repair channel — because a pin nothing reads is a
|
|
// column, not a pin: the engine ships more often than a run finishes, and asking the CURRENT
|
|
// binary about a book an older one is translating is a different question.
|
|
EngineBinary string
|
|
// SpendBaseline is what the BOOK had already cost when this attempt began. Nil means it was never
|
|
// captured, which no current path produces (the spawn refuses without it).
|
|
SpendBaseline *money.MicroUSD
|
|
// CeilingArg is the limit a PREVIOUS claim of this attempt already handed the engine. Read back
|
|
// because a retry must hand the same one: recomputing it from a counter that the first claim's own
|
|
// engine has been moving gives that engine a second, larger limit and leaves the column that is
|
|
// supposed to answer "what limit did that process have" describing neither. Zero means no claim
|
|
// has been recorded (the column predates nothing else being able to tell).
|
|
CeilingArg money.MicroUSD
|
|
// PausedReason is what the stream already said about this run. A run that reported a ceiling halt
|
|
// is `paused` however its process then ended (contract §BookStatus: never `failed`).
|
|
PausedReason string
|
|
// Status is the run's product status. The reconciler works on live runs, where it is
|
|
// `translating`; the resume path reads a run that has already ended and decides by it.
|
|
Status string
|
|
// StopRequestedAt is when THIS platform asked the run to stop, and it is the only thing that can
|
|
// tell a stop from a crash: the engine catches SIGTERM and exits 1, so the marker says
|
|
// `exit-code/exited/1` for both (register row PD-152). Nil means nobody asked.
|
|
StopRequestedAt *time.Time
|
|
// StartedAt is the RUN's start; AttemptStartedAt is THIS attempt's. The reconciler's grace is
|
|
// measured against the second: a restarted attempt inherits a start time hours old, and the
|
|
// grace then expires before systemd has had a chance to create anything.
|
|
StartedAt time.Time
|
|
AttemptStartedAt time.Time
|
|
}
|
|
|
|
// Position mirrors ingest.Position without importing it: this package owns the columns, and the
|
|
// dependency runs the other way.
|
|
type Position struct {
|
|
Offset int64
|
|
LastSeq int64
|
|
LastHash []byte
|
|
}
|
|
|
|
// ListLiveRuns returns every run that has not finished, with its live attempt.
|
|
//
|
|
// This is the reconciler's source of truth, together with the book's directory — NOT systemd
|
|
// (research/25 §Опс). A transient unit does not survive a reboot and is unloaded the moment it
|
|
// exits, so asking systemd "what is running" answers a different question than "what did this
|
|
// platform promise a user".
|
|
func (s *Store) ListLiveRuns(ctx context.Context) ([]LiveRun, error) {
|
|
return s.queryRuns(ctx, `
|
|
join run_attempts a on a.run_id = r.id and a.ended_at is null
|
|
left join reservations res on res.engine_run_id = r.id || '#' || a.attempt_no
|
|
and res.state = 'open'
|
|
where r.finished_at is null
|
|
order by r.started_at`)
|
|
}
|
|
|
|
// runColumns is the reconciler's view of a run. Written once because the two queries that use it
|
|
// differ only in which runs they select, and a scan list copied twice is a scan list that drifts.
|
|
const runColumns = `
|
|
select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.ceiling_chapters,
|
|
coalesce(r.paused_reason, ''), r.started_at, r.status, r.stop_requested_at,
|
|
a.id, a.attempt_no, coalesce(a.unit_name, ''), coalesce(a.engine_run_id, ''),
|
|
a.last_offset, a.last_seq, a.last_line_sha256, a.quarantine_reason is not null,
|
|
coalesce(res.amount_micro_usd, 0), a.engine_binary, a.spend_baseline_micro_usd,
|
|
a.ceiling_arg_micro_usd, a.started_at
|
|
from runs r
|
|
join books b on b.id = r.book_id`
|
|
|
|
func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]LiveRun, error) {
|
|
rows, err := s.pool.Query(ctx, runColumns+tail, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pgstore: list runs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []LiveRun
|
|
for rows.Next() {
|
|
var l LiveRun
|
|
var ceiling, ceilingArg int64
|
|
var baseline *int64
|
|
if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.CeilingChapters,
|
|
&l.PausedReason, &l.StartedAt, &l.Status, &l.StopRequestedAt,
|
|
&l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID,
|
|
&l.Position.Offset, &l.Position.LastSeq, &l.Position.LastHash, &l.Quarantined, &ceiling,
|
|
&l.EngineBinary, &baseline, &ceilingArg, &l.AttemptStartedAt); err != nil {
|
|
return nil, fmt.Errorf("pgstore: scan run: %w", err)
|
|
}
|
|
l.Ceiling = money.MicroUSD(ceiling)
|
|
l.CeilingArg = money.MicroUSD(ceilingArg)
|
|
if baseline != nil {
|
|
v := money.MicroUSD(*baseline)
|
|
l.SpendBaseline = &v
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RunSpent is what a run has actually been charged so far, across all its attempts. Settlements are
|
|
// ledger rows keyed by attempt, so the sum is over the run's own key space and nothing else's.
|
|
func (s *Store) RunSpent(ctx context.Context, runID string) (money.MicroUSD, error) {
|
|
var v int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
select coalesce(-sum(amount_micro_usd), 0) from credit_ledger
|
|
where source = 'run_settle' and split_part(source_id, '#', 1) = $1`, runID).Scan(&v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: read run spend: %w", err)
|
|
}
|
|
return money.MicroUSD(v), nil
|
|
}
|
|
|
|
// SpendBound is the UPPER bound on what an attempt can have cost: the smallest meter reading any
|
|
// LATER attempt of the same book recorded before it started.
|
|
//
|
|
// It exists because settlement reads a lifetime counter of the BOOK at the moment it retries, and
|
|
// that retry can happen after another run of the same book has already moved it. A settlement is
|
|
// allowed to defer — the engine has to be asked and asking can fail — and a deferred one is not
|
|
// blocked from being overtaken: the earlier run is finished, so nothing stops the account starting
|
|
// another. Measured: a run that cost $0.10 was charged $2.10, the difference being what its
|
|
// successor had spent by then, and the successor then paid that same amount again.
|
|
//
|
|
// A later attempt's baseline is exactly the right bound, because it was read BEFORE that attempt
|
|
// added anything and AFTER this one had stopped. Nil means no later attempt has been spawned, and
|
|
// then the counter has not been moved by anyone else.
|
|
func (s *Store) SpendBound(ctx context.Context, bookID string, attemptID int64) (*money.MicroUSD, error) {
|
|
var v *int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
select min(a.spend_baseline_micro_usd)
|
|
from run_attempts a join runs r on r.id = a.run_id
|
|
where r.book_id = $1 and a.id > $2 and a.spend_baseline_micro_usd is not null`,
|
|
bookID, attemptID).Scan(&v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pgstore: read spend bound: %w", err)
|
|
}
|
|
if v == nil {
|
|
return nil, nil
|
|
}
|
|
bound := money.MicroUSD(*v)
|
|
return &bound, nil
|
|
}
|
|
|
|
// ErrRunNotLive is a run that cannot be stopped because it is already over. Distinct from ErrNoRun,
|
|
// which is a run this account cannot see at all: the first is the contract's 409 and the second its
|
|
// 404, and answering the wrong one either tells a stranger that a run exists or tells an owner that
|
|
// theirs does not.
|
|
var ErrRunNotLive = errors.New("pgstore: the run is not live")
|
|
|
|
// ErrStopRequested is a restart refused because the run it would restart has been asked to stop. Not
|
|
// an error of the caller: the reconciler ends the run instead.
|
|
var ErrStopRequested = errors.New("pgstore: the run has been asked to stop")
|
|
|
|
// RequestStop records that THIS platform asked a run to stop, and hands back the unit to ask.
|
|
//
|
|
// The order is the whole design (migration 00014): the intent is COMMITTED before systemd is
|
|
// touched, so a platform that dies between the two still knows on its next sweep that the run it
|
|
// finds ended was stopped on purpose — and, just as important, that it must not restart it.
|
|
//
|
|
// Idempotent by coalesce: pressing stop twice is one intent with the FIRST timestamp, because the
|
|
// timestamp is evidence about which of the two events came first and a later one would erase that.
|
|
//
|
|
// The revision it answers with is the BOOK's — see ReadRun for why every run-carrying response uses
|
|
// one counter.
|
|
func (s *Store) RequestStop(ctx context.Context, userID, runID string, now time.Time) (Run, string, error) {
|
|
const q = `
|
|
update runs r set stop_requested_at = coalesce(r.stop_requested_at, $3)
|
|
from books b
|
|
where r.id = $1 and b.id = r.book_id and b.owner_id = $2 and r.finished_at is null
|
|
returning r.id, r.book_id, b.revision, r.status, r.verify_bank, r.ceiling_chapters,
|
|
coalesce(r.paused_reason, ''), r.started_at, r.finished_at,
|
|
coalesce((select a.unit_name from run_attempts a
|
|
where a.run_id = r.id and a.ended_at is null
|
|
order by a.attempt_no desc limit 1), '')`
|
|
var out Run
|
|
var unit string
|
|
err := s.pool.QueryRow(ctx, q, runID, userID, now).Scan(&out.ID, &out.BookID, &out.Revision,
|
|
&out.Status, &out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt,
|
|
&out.FinishedAt, &unit)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Nothing matched, and the two reasons need different answers. Asked separately and only on
|
|
// this path, so the ordinary stop stays one round trip.
|
|
return Run{}, "", s.whyNotLive(ctx, userID, runID)
|
|
}
|
|
if err != nil {
|
|
return Run{}, "", fmt.Errorf("pgstore: request stop: %w", err)
|
|
}
|
|
return out, unit, nil
|
|
}
|
|
|
|
func (s *Store) whyNotLive(ctx context.Context, userID, runID string) error {
|
|
var visible bool
|
|
if err := s.pool.QueryRow(ctx, `
|
|
select exists (select 1 from runs r join books b on b.id = r.book_id
|
|
where r.id = $1 and b.owner_id = $2)`, runID, userID).Scan(&visible); err != nil {
|
|
return fmt.Errorf("pgstore: read run: %w", err)
|
|
}
|
|
if visible {
|
|
return ErrRunNotLive
|
|
}
|
|
return ErrNoRun
|
|
}
|
|
|
|
// ReadRunForResume loads a run and its LAST attempt, whether or not either is still live.
|
|
//
|
|
// The reconciler's own list is deliberately not reusable here: it selects the attempt that has not
|
|
// ended, and every run this call is about has ended. What resume needs is the state the run stopped
|
|
// in and the attempt whose money and journal position it stopped at.
|
|
func (s *Store) ReadRunForResume(ctx context.Context, userID, runID string) (LiveRun, error) {
|
|
rows, err := s.queryRuns(ctx, `
|
|
join run_attempts a on a.run_id = r.id
|
|
and a.attempt_no = (select max(attempt_no) from run_attempts
|
|
where run_id = r.id)
|
|
left join reservations res on res.engine_run_id = r.id || '#' || a.attempt_no
|
|
and res.state = 'open'
|
|
where r.id = $1 and b.owner_id = $2`, runID, userID)
|
|
if err != nil {
|
|
return LiveRun{}, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return LiveRun{}, ErrNoRun
|
|
}
|
|
return rows[0], nil
|
|
}
|
|
|
|
// ReadRun is the run row as the contract projects it, read under the caller's ownership.
|
|
//
|
|
// ⚠ The revision is the BOOK's, not the `runs.revision` column, and that is the contract rather than
|
|
// a shortcut: "the counter is PER BOOK — every book-scoped read and the id of every stream frame of
|
|
// that book's run carry the same number" (§Revision), and a client MUST DROP a read whose revision is
|
|
// below one it has applied. The book card already answers this way (httpapi getBook); a handle that
|
|
// answered from the run's own column would hand the client a number below the card's and the client,
|
|
// obeying the contract, would drop the answer to the button it just pressed.
|
|
func (s *Store) ReadRun(ctx context.Context, userID, runID string) (Run, error) {
|
|
const q = `
|
|
select r.id, r.book_id, b.revision, r.status, r.verify_bank, r.ceiling_chapters,
|
|
coalesce(r.paused_reason, ''), r.started_at, r.finished_at
|
|
from runs r join books b on b.id = r.book_id
|
|
where r.id = $1 and b.owner_id = $2`
|
|
var out Run
|
|
err := s.pool.QueryRow(ctx, q, runID, userID).Scan(&out.ID, &out.BookID, &out.Revision, &out.Status,
|
|
&out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt, &out.FinishedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Run{}, ErrNoRun
|
|
}
|
|
if err != nil {
|
|
return Run{}, fmt.Errorf("pgstore: read run: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// RestartInput is one interrupted attempt being replaced.
|
|
type RestartInput struct {
|
|
RunID string
|
|
AttemptID int64
|
|
UserID string
|
|
BookID string
|
|
// Ceiling is what is LEFT of the run's budget. A restart that reserved the full ceiling again
|
|
// would let one run spend it twice.
|
|
Ceiling money.MicroUSD
|
|
Offset int64
|
|
// EngineBinary overrides the pinned path. Nil means INHERIT — which is the default, because a
|
|
// resume is the same run continuing and row 139 lets another version in only on purpose.
|
|
EngineBinary *string
|
|
Now time.Time
|
|
}
|
|
|
|
// RestartRun closes an interrupted attempt and opens the next one, with its own hold, in ONE
|
|
// transaction — the same reason StartRun is one transaction.
|
|
//
|
|
// The old attempt's reservation is NOT carried over: it was taken for a process that no longer
|
|
// exists, and it is closed by the settlement that runs before this.
|
|
func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error) {
|
|
var out LiveRun
|
|
err := s.inTx(ctx, func(tx Tx) error {
|
|
// Book first, as in every other transaction that touches both (lockBook). This one took the
|
|
// attempt first and the materializer takes the book first, which is the pair that deadlocked.
|
|
if err := lockBook(ctx, tx, in.BookID); err != nil {
|
|
return err
|
|
}
|
|
// The RUN row second — the order lockBook writes down, which this transaction used to take
|
|
// last, after the money. Taking it here also does the work below: what is read from the row is
|
|
// read under the lock that will do the writing.
|
|
var stopRequested, finished *time.Time
|
|
if err := tx.QueryRow(ctx,
|
|
`select stop_requested_at, finished_at from runs where id = $1 for update`, in.RunID).
|
|
Scan(&stopRequested, &finished); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNoRun
|
|
}
|
|
return fmt.Errorf("pgstore: lock run: %w", err)
|
|
}
|
|
// A stop asked for on a run that is STILL LIVE outranks a restart, and the check belongs here
|
|
// rather than in the caller's snapshot: the reconciler decides to restart, spends seconds
|
|
// settling (a `tmctl status` call), and the user presses stop inside that window. Restarting
|
|
// then cleared the fresh intent, opened a second attempt and took a new hold — the user had a
|
|
// 202 for a stop that never happened and paid for the work they had just cancelled.
|
|
//
|
|
// On a FINISHED run the same column is history: a resume re-opens a run that was stopped, and
|
|
// the intent belongs to the life that ended — it is cleared below with the rest of what said
|
|
// the run was over.
|
|
//
|
|
// A stop arriving DURING this transaction is not lost either: it waits on this row lock and
|
|
// lands on the re-opened run, which the next sweep then stops.
|
|
if stopRequested != nil && finished == nil {
|
|
return ErrStopRequested
|
|
}
|
|
// The previous attempt is closed if it is still open, and left exactly as it is if it is not.
|
|
// Both callers arrive here: the reconciler replaces an attempt that was INTERRUPTED and is
|
|
// still open, while a resume continues a run whose attempt already ended with a verdict of its
|
|
// own — and overwriting that verdict would erase how the run the user stopped actually ended.
|
|
//
|
|
// What serializes two callers is no longer this row but the next one: `unique (run_id,
|
|
// attempt_no)` lets exactly one of them insert attempt N+1, and the loser gets the same
|
|
// ErrNoRun it always got.
|
|
var attemptNo int
|
|
if err := tx.QueryRow(ctx, `
|
|
update run_attempts set ended_at = coalesce(ended_at, $2),
|
|
exit_result = coalesce(exit_result, 'interrupted')
|
|
where id = $1 returning attempt_no`, in.AttemptID, in.Now).Scan(&attemptNo); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNoRun // the attempt is gone
|
|
}
|
|
return fmt.Errorf("pgstore: close interrupted attempt: %w", err)
|
|
}
|
|
next := attemptNo + 1
|
|
var id int64
|
|
var pinned string
|
|
// The new attempt INHERITS the binary the run was pinned to (unified backlog row 139): the
|
|
// engine ships more often than a run finishes, so resuming on whatever was deployed since is a
|
|
// different program continuing someone else's work. Changing it is allowed, but only by saying
|
|
// so — see runs.Config.AllowEngineVersionChange.
|
|
if err := tx.QueryRow(ctx, `
|
|
insert into run_attempts (run_id, attempt_no, started_at, last_offset, engine_binary)
|
|
select $1, $2, $3, $4, coalesce($5, prev.engine_binary, '')
|
|
from run_attempts prev where prev.id = $6
|
|
returning id, engine_binary`, in.RunID, next, in.Now, in.Offset, in.EngineBinary, in.AttemptID).
|
|
Scan(&id, &pinned); err != nil {
|
|
if isUnique(err, "run_attempts_run_id_attempt_no_key") {
|
|
return ErrNoRun // another caller opened this attempt first
|
|
}
|
|
return fmt.Errorf("pgstore: open next attempt: %w", err)
|
|
}
|
|
if err := holdTx(ctx, tx, in.UserID, in.BookID, engineRunKey(in.RunID, next), in.Ceiling, in.Now); err != nil {
|
|
return err
|
|
}
|
|
// finished_at, settled_at and stop_requested_at are CLEARED, and each for its own reason. The
|
|
// reconciler's restart works on a run where all three are already null, so there it changes
|
|
// nothing; a RESUME re-opens a run that ended, and leaving them would mean a live run the
|
|
// one-live-per-book index does not see, money marked resolved that this attempt has not spent
|
|
// yet, and a stop request from the previous life that would classify this attempt's ending as
|
|
// a stop nobody asked for.
|
|
if _, err := tx.Exec(ctx, `
|
|
update runs set status = 'translating', paused_reason = null, finished_at = null,
|
|
settled_at = null, stop_requested_at = null,
|
|
revision = (select revision + 1 from books where id = runs.book_id)
|
|
where id = $1`, in.RunID); err != nil {
|
|
// Clearing finished_at puts the run back under the one-live-run-per-book index, and the
|
|
// book may already have a NEWER live run — nothing stops an account starting one after it
|
|
// stopped this one. That is a conflict and not a failure: the whole transaction rolls back,
|
|
// so the hold taken three lines above is undone with it, and the caller gets the same error
|
|
// a second admission would have got.
|
|
if isUnique(err, "runs_one_live_per_book") {
|
|
return ErrRunInFlight
|
|
}
|
|
return fmt.Errorf("pgstore: reopen run: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
update books set status = 'translating',
|
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`,
|
|
in.BookID); err != nil {
|
|
return fmt.Errorf("pgstore: mark book translating: %w", err)
|
|
}
|
|
out = LiveRun{RunID: in.RunID, BookID: in.BookID, UserID: in.UserID, AttemptID: id,
|
|
AttemptNo: next, Ceiling: in.Ceiling, StartedAt: in.Now, AttemptStartedAt: in.Now,
|
|
EngineBinary: pinned, Position: Position{Offset: in.Offset}}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return LiveRun{}, err
|
|
}
|
|
// The fields the caller needs to spawn but this transaction did not read.
|
|
const q = `select b.workdir, r.verify_bank, r.ceiling_chapters from runs r
|
|
join books b on b.id = r.book_id where r.id = $1`
|
|
if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.CeilingChapters); err != nil {
|
|
return LiveRun{}, fmt.Errorf("pgstore: read restarted run: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ReleaseSpawnClaim gives an attempt back after a unit could NOT be created, so the next sweep
|
|
// retries this attempt instead of reading a recorded-but-absent unit as an interrupted run.
|
|
func (s *Store) ReleaseSpawnClaim(ctx context.Context, attemptID int64) error {
|
|
_, err := s.pool.Exec(ctx,
|
|
`update run_attempts set unit_name = null where id = $1`, attemptID)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: release spawn claim: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AttemptReservationOpen reports whether an attempt's money is still reserved. The restart path asks
|
|
// before it opens a SECOND reservation: settling can legitimately fail (the engine's figure could not
|
|
// be read), and proceeding then holds the ceiling twice and strands the first hold where no sweep
|
|
// looks for it again.
|
|
func (s *Store) AttemptReservationOpen(ctx context.Context, runID string, attempt int) (bool, error) {
|
|
var open bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
select exists (select 1 from reservations where engine_run_id = $1 and state = 'open')`,
|
|
engineRunKey(runID, attempt)).Scan(&open)
|
|
if err != nil {
|
|
return false, fmt.Errorf("pgstore: read reservation state: %w", err)
|
|
}
|
|
return open, nil
|
|
}
|
|
|
|
// PauseRun records a run that cannot go on, without pretending it failed.
|
|
func (s *Store) PauseRun(ctx context.Context, runID string, attemptID int64, reason string, now time.Time) error {
|
|
if reason != PausedCreditExhausted {
|
|
return fmt.Errorf("pgstore: %q is not a pause reason", reason)
|
|
}
|
|
return s.inTx(ctx, func(tx Tx) error {
|
|
// Book first, as everywhere else that touches both (see FinishRun).
|
|
var bookID string
|
|
if err := tx.QueryRow(ctx,
|
|
`select id from books where id = (select book_id from runs where id = $1) for update`,
|
|
runID).Scan(&bookID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("pgstore: lock book: %w", err)
|
|
}
|
|
// A stop the user asked for outranks a pause, and the check belongs inside this TRANSACTION,
|
|
// under the same locks: the reconciler decides to pause after settling — seconds of a
|
|
// `tmctl status` call — and a stop landing inside that window would otherwise be answered with
|
|
// `paused/credit_exhausted`, which
|
|
// says the money ran out when what happened is that its owner stopped it.
|
|
var stopRequested *time.Time
|
|
if err := tx.QueryRow(ctx,
|
|
`select stop_requested_at from runs where id = $1 for update`, runID).Scan(&stopRequested); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("pgstore: lock run: %w", err)
|
|
}
|
|
if stopRequested != nil {
|
|
return ErrStopRequested
|
|
}
|
|
// The attempt being closed must still be a LIVE attempt OF THIS RUN — the third path to carry
|
|
// the guard `FinishRun` (PD-181) and `FinishUnspawnedStop` (FP5-2) already have, and it was
|
|
// missing here. Without it a pass holding an old snapshot pauses a run whose attempt has since
|
|
// been restarted: the run reads `finished`, the SECOND attempt's hold stays open and falls out
|
|
// of both `ListLiveRuns` (the run is finished) and `UnsettledRuns` (the attempt is not), and an
|
|
// engine keeps spending under a run its owner is told ran out of money. Found by cross-family
|
|
// review of the acceptance dofix.
|
|
if err := tx.QueryRow(ctx, `
|
|
update runs set status = 'paused', paused_reason = $2, finished_at = $3,
|
|
revision = (select revision + 1 from books where id = runs.book_id)
|
|
where id = $1 and finished_at is null
|
|
and exists (select 1 from run_attempts a
|
|
where a.id = $4 and a.run_id = runs.id and a.ended_at is null)
|
|
returning book_id`, runID, reason, now, attemptID).Scan(&bookID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("pgstore: pause run: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
update run_attempts set ended_at = coalesce(ended_at, $3)
|
|
where id = $1 and run_id = $2`, attemptID, runID, now); err != nil {
|
|
return fmt.Errorf("pgstore: end attempt: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
update books set status = 'paused',
|
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID); err != nil {
|
|
return fmt.Errorf("pgstore: pause book: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// RunForSpawn is what the worker needs to spawn an attempt.
|
|
type RunForSpawn struct {
|
|
LiveRun
|
|
// AlreadySpawned is a unit name that was recorded for this attempt. The worker must NOT spawn a
|
|
// second engine for it: a queue job can be retried after a platform restart, and the run it
|
|
// refers to may well still be running.
|
|
AlreadySpawned bool
|
|
}
|
|
|
|
// ReadRunForSpawn loads the live attempt of a run.
|
|
func (s *Store) ReadRunForSpawn(ctx context.Context, runID string) (RunForSpawn, error) {
|
|
live, err := s.ListLiveRuns(ctx)
|
|
if err != nil {
|
|
return RunForSpawn{}, err
|
|
}
|
|
for _, l := range live {
|
|
if l.RunID == runID {
|
|
return RunForSpawn{LiveRun: l, AlreadySpawned: l.UnitName != ""}, nil
|
|
}
|
|
}
|
|
return RunForSpawn{}, ErrNoRun
|
|
}
|
|
|
|
// SpawnRecord is what is written down about an attempt just before its unit is created.
|
|
type SpawnRecord struct {
|
|
AttemptID int64
|
|
Unit string
|
|
// Binary is the versioned engine path this attempt is pinned to (unified backlog row 139).
|
|
Binary string
|
|
// Ceiling is the attempt's own budget: the increment the user bought and the amount held.
|
|
Ceiling money.MicroUSD
|
|
// CeilingArg is what the engine is actually told, which is the book's cumulative cap and
|
|
// therefore a different number from the second run of a book onwards (D39.122).
|
|
CeilingArg money.MicroUSD
|
|
// Baseline is where the book's lifetime meter stood before this attempt added to it.
|
|
Baseline money.MicroUSD
|
|
}
|
|
|
|
// RecordSpawn claims the right to start this attempt, and writes down what is about to run: the
|
|
// unit, the binary version it is pinned to, the budget it carries and the ceiling it is given.
|
|
// Written BEFORE the unit is created, so a crash between the two leaves a record to reconcile rather
|
|
// than an unattributable process.
|
|
//
|
|
// claimed is false when the attempt already has a unit name, when the attempt has ENDED, or when its
|
|
// run is over. It is a COMPARE-AND-SET rather than a plain update because two callers legitimately
|
|
// reach here at once — the queue worker that was handed the run and the reconciler that found it
|
|
// unspawned — and the loser must not start a second engine. systemd would refuse the duplicate NAME,
|
|
// so that accident was survivable; surviving by someone else's uniqueness rule is not the same as
|
|
// being correct, and the day a resume changes the naming it stops holding.
|
|
//
|
|
// ⚠ The three conditions BESIDES the unit name are money, and they were not here until a run could be
|
|
// stopped before it ever spawned. The window: a worker sits inside `bookMeter` for the seconds a
|
|
// `tmctl status` takes, the user stops the run, the sweep ends it and gives the whole hold back
|
|
// (nothing was spawned) — and the worker then wakes up and creates a unit for a run that is finished
|
|
// and settled. That engine would spend against its own book cap with NO open reservation, and
|
|
// nothing would ever look at it: the reconciler lists runs by `finished_at is null` and settlements
|
|
// by an open reservation, so it is in neither list. The stop INTENT is asked about one step earlier
|
|
// for the same reason: it lands while the worker is inside that same `bookMeter`, before any sweep
|
|
// could have finished the run, and a claim granted then starts an engine for work its owner
|
|
// cancelled before it began. All three are asked in the statement that claims, because a check made
|
|
// before it is a check with a window after it.
|
|
func (s *Store) RecordSpawn(ctx context.Context, r SpawnRecord) (claimed bool, err error) {
|
|
// What a PREVIOUS claim of this attempt decided is kept. The claim is given back when the unit
|
|
// could not be created (ReleaseSpawnClaim), and "could not be created" is not the same as "was not
|
|
// created": a systemd-run that was killed after it had already asked for the unit reports a failure
|
|
// and leaves an engine running. The next claim would then overwrite the baseline with a meter that
|
|
// engine has been moving, and the attempt would be billed for the difference from a figure that
|
|
// already includes its own work — an underpayment nothing later looks for. Where the unit really
|
|
// was not created the two values are identical, so keeping the first costs nothing.
|
|
tag, err := s.pool.Exec(ctx, `
|
|
update run_attempts
|
|
set unit_name = $2, engine_binary = $3, ceiling_micro_usd = $4,
|
|
ceiling_arg_micro_usd = case when spend_baseline_micro_usd is null
|
|
then $5 else ceiling_arg_micro_usd end,
|
|
spend_baseline_micro_usd = coalesce(spend_baseline_micro_usd, $6)
|
|
where id = $1 and unit_name is null and ended_at is null
|
|
and exists (select 1 from runs r where r.id = run_attempts.run_id
|
|
and r.finished_at is null and r.stop_requested_at is null)`,
|
|
r.AttemptID, r.Unit, r.Binary, int64(r.Ceiling), int64(r.CeilingArg), int64(r.Baseline))
|
|
if err != nil {
|
|
return false, fmt.Errorf("pgstore: record spawn: %w", err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
// SaveCursor persists the tailer's position when nothing was materialized — a re-read of lines the
|
|
// cursor already covers still moves the byte hint, and losing that means re-reading them forever.
|
|
func (s *Store) SaveCursor(ctx context.Context, attemptID int64, p Position) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
update run_attempts set last_offset = $2 where id = $1 and last_offset < $2`, attemptID, p.Offset)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: save cursor: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Quarantine stops materializing an attempt without touching the run.
|
|
//
|
|
// The engine is NOT stopped: it is spending money the account has already reserved, and our
|
|
// inability to read its journal is not a reason to throw that away. What stops is the projection —
|
|
// after this the run's state is only as fresh as the resync channel makes it, and the reason says
|
|
// so out loud rather than leaving a screen that quietly stopped moving.
|
|
func (s *Store) Quarantine(ctx context.Context, attemptID int64, reason string) error {
|
|
_, err := s.pool.Exec(ctx,
|
|
`update run_attempts set quarantine_reason = $2 where id = $1 and quarantine_reason is null`,
|
|
attemptID, reason)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: quarantine attempt: %w", err)
|
|
}
|
|
return nil
|
|
}
|