package pgstore import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "textmachine/platform/internal/ingest" ) // RunSink materializes one attempt's event stream into the reporting database. // // It is bound to an ATTEMPT rather than to a run, because the ratified idempotency key is // (engine_run_id, seq) and the engine mints a new run id — with seq restarting at 1 — on every // invocation. Keying on the platform's run would drop the whole stream of attempt two. type RunSink struct { store *Store attemptID int64 runID string bookID string } // NewRunSink builds the sink for one attempt. func (s *Store) NewRunSink(attemptID int64, runID, bookID string) *RunSink { return &RunSink{store: s, attemptID: attemptID, runID: runID, bookID: bookID} } // Begin binds the engine's run id to this attempt. // // The binding is refused if the attempt already carries a DIFFERENT id: that means two engine // processes wrote into one journal under one attempt, and materializing either of them would mix // two runs' counters into one projection. // // It is refused for an ENDED attempt too, and that half is newer than the rule above. A stop that // landed before its engine was spawned leaves an attempt that is closed and carries no engine run id // — a shape this zone could not produce before P5 — and an unbound, ended attempt would otherwise // ADOPT the handshake of the attempt that replaced it: the same events would then be materialized // twice, once per attempt, and every counter they increment would be counted twice. Found by // cross-family review of the acceptance dofix. func (r *RunSink) Begin(ctx context.Context, h ingest.Hello) error { tag, err := r.store.pool.Exec(ctx, ` update run_attempts set engine_run_id = $2 where id = $1 and ended_at is null and (engine_run_id is null or engine_run_id = $2)`, r.attemptID, h.EngineRunID) if err != nil { return fmt.Errorf("pgstore: bind engine run: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("pgstore: attempt %d is over, or already bound to another engine run", r.attemptID) } if h.ChunkerVersion != "" { if _, err := r.store.pool.Exec(ctx, `update books set chunker_version = $2 where id = $1`, r.bookID, h.ChunkerVersion); err != nil { return fmt.Errorf("pgstore: record chunker version: %w", err) } } return nil } // Apply materializes one event AND the cursor it moves, in ONE transaction. // // That is the whole reason this method exists rather than two: an implementation that applies the // effect and then records the position has a window in which a crash re-applies the effect, and an // implementation that records first has a window in which the effect is lost. The high-water mark is // re-checked inside the transaction, so a duplicate line that raced a live writer is dropped here // too and not only in the reader (PD-105: at-least-once delivery, a duplicate is not an error). // // The BOOK is locked first — see lockBook. This path used to take the attempt first and every other // path that touches both takes the book first, which is a deadlock the moment a materializer and a // reconciler work on one run: measured on the real API at 258 of 300 concurrent pairs. func (r *RunSink) Apply(ctx context.Context, ev ingest.Envelope, c ingest.Cursor) error { return r.store.inTx(ctx, func(tx pgx.Tx) error { if err := lockBook(ctx, tx, r.bookID); err != nil { return err } var last int64 if err := tx.QueryRow(ctx, `select last_seq from run_attempts where id = $1 for update`, r.attemptID).Scan(&last); err != nil { return fmt.Errorf("pgstore: lock attempt: %w", err) } if ev.Seq <= last { return nil // already applied; the cursor cannot move backwards either } if err := r.effect(ctx, tx, ev); err != nil { return err } if _, err := tx.Exec(ctx, ` update run_attempts set last_seq = $2, last_offset = greatest(last_offset, $3), last_line_sha256 = $4 where id = $1`, r.attemptID, ev.Seq, c.Offset, c.SHA256); err != nil { return fmt.Errorf("pgstore: move cursor: %w", err) } return nil }) } // effect is what one event changes. An unknown type changes nothing and is not an error: tolerating // it is the minor-version rule of the stream (D39.85). func (r *RunSink) effect(ctx context.Context, tx pgx.Tx, ev ingest.Envelope) error { switch ev.Type { case ingest.TypeProgress: var p ingest.Progress if err := decode(ev, &p); err != nil { return err } return r.bump(ctx, tx, ` update runs set draft_done = $2, draft_total = $3, edit_done = $4, edit_total = $5, eta_seconds = $6, revision = $7 where id = $1`, r.runID, p.Draft.Done, p.Draft.Total, p.Edit.Done, p.Edit.Total, etaOrNil(p.ETASeconds)) case ingest.TypeUnitDone: var u ingest.UnitDone if err := decode(ev, &u); err != nil { return err } return r.unitDone(ctx, tx, u) case ingest.TypeBankStop: return r.bump(ctx, tx, `update runs set status = 'awaiting_bank', revision = $2 where id = $1`, r.runID) case ingest.TypeCeiling: var c ingest.Ceiling if err := decode(ev, &c); err != nil { return err } if !c.Halted { return nil } // A ceiling stop is `paused`, never `failed`: it is resumable, and mapping it to a failure // would lie about that (contract §BookStatus). return r.bump(ctx, tx, ` update runs set status = 'paused', paused_reason = 'credit_exhausted', revision = $2 where id = $1`, r.runID) case ingest.TypeSpend: var s ingest.Spend if err := decode(ev, &s); err != nil { return err } // The MAXIMUM seen, not a sum: the counter is cumulative, so a redelivered line is harmless // only as long as nothing adds it up. Freshness only — no balance moves here. _, err := tx.Exec(ctx, ` update run_attempts set spend_micro_usd = greatest(spend_micro_usd, $2) where id = $1`, r.attemptID, s.CommittedMicroUSD) if err != nil { return fmt.Errorf("pgstore: record spend: %w", err) } return nil case ingest.TypeFinished: // The stream says the engine believes it is done. The RUN is not closed here: closing it // settles money, and money is settled from what the process actually did — which is known // once the unit is gone, not once a line was written. return nil default: return nil } } // unitDone folds one resolved edit unit into its chapter. // // ⚠ Bounded by what exists: without the engine's persisted chapter manifest (unified backlog row // 100) the platform has no mapping from the engine's chapter NUMBER to a stable chapter id, so a // book whose chapters have never been materialized has nothing to update. The counters that DO have // a home — the run's own progress — are carried by the progress event, so nothing is lost that the // screen reads today. func (r *RunSink) unitDone(ctx context.Context, tx pgx.Tx, u ingest.UnitDone) error { col := "units_draft_done" if u.Wave == "edit" { col = "units_edit_done" } tag, err := tx.Exec(ctx, ` update chapters set `+col+` = `+col+` + 1, units_done = case when $3 then units_done + 1 else units_done end, revision = (select revision + 1 from books where id = $1) where book_id = $1 and number = $2`, r.bookID, u.Chapter, u.Wave == "edit") if err != nil { return fmt.Errorf("pgstore: fold unit: %w", err) } if tag.RowsAffected() == 0 { return nil // no manifest yet — see the note above } return r.bumpBook(ctx, tx) } // bump runs a statement whose LAST argument is the book's next revision, and stamps the book with // it. One transaction is one revision but possibly several rows, which is why catch-up reads use // `>=` and not `>` (contract §Revision). func (r *RunSink) bump(ctx context.Context, tx pgx.Tx, q string, args ...any) error { rev, err := r.nextRevision(ctx, tx) if err != nil { return err } if _, err := tx.Exec(ctx, q, append(args, rev)...); err != nil { return fmt.Errorf("pgstore: materialize event: %w", err) } return r.bumpBook(ctx, tx) } func (r *RunSink) nextRevision(ctx context.Context, tx pgx.Tx) (int64, error) { var rev int64 if err := tx.QueryRow(ctx, `select revision + 1 from books where id = $1 for update`, r.bookID).Scan(&rev); err != nil { return 0, fmt.Errorf("pgstore: read revision: %w", err) } return rev, nil } func (r *RunSink) bumpBook(ctx context.Context, tx pgx.Tx) error { if _, err := tx.Exec(ctx, `update books set revision = revision + 1 where id = $1`, r.bookID); err != nil { return fmt.Errorf("pgstore: bump book revision: %w", err) } return nil } func decode(ev ingest.Envelope, into any) error { if len(ev.Data) == 0 { return fmt.Errorf("pgstore: event %s seq %d carries no data", ev.Type, ev.Seq) } if err := json.Unmarshal(ev.Data, into); err != nil { return fmt.Errorf("pgstore: event %s seq %d: %w", ev.Type, ev.Seq, err) } return nil } func etaOrNil(s int) *int { if s <= 0 { return nil // absent, not zero: the screen renders without it rather than showing "0 s left" } return &s } // ApplyStatus folds a `tmctl status --json` report into the read model. // // This is the RESYNC channel: a snapshot of a run that keeps moving, taken every few minutes. Since // D39.122 the report carries the per-wave split, so it materializes through the same four counters // as the stream and no longer flattens them — what stays true of it is that it is STALE by up to // the poll interval, which is why it stamps last_resync_at rather than leaving a reader to guess // how old the figures on a quarantined run are. func (s *Store) ApplyStatus(ctx context.Context, runID, bookID string, rep ingest.StatusReport, now time.Time) error { return s.inTx(ctx, func(tx pgx.Tx) error { var rev int64 if err := tx.QueryRow(ctx, `select revision + 1 from books where id = $1 for update`, bookID).Scan(&rev); err != nil { return fmt.Errorf("pgstore: read revision: %w", err) } // greatest(), so a resync can never move a counter BACKWARDS. A report taken before the // engine's own figures caught up would otherwise walk a visible progress bar back down — the // one thing the contract asks a client never to do and which the server must not do either. // It is also what makes this safe to run over a projection the stream has already moved. if _, err := tx.Exec(ctx, ` update runs set draft_done = greatest(draft_done, $2), draft_total = greatest(draft_total, $3), edit_done = greatest(edit_done, $4), edit_total = greatest(edit_total, $5), eta_seconds = $6, last_resync_at = $7, revision = $8 where id = $1`, runID, rep.Progress.Draft.Done, rep.Progress.Draft.Total, rep.Progress.Edit.Done, rep.Progress.Edit.Total, etaOrNil(int(rep.ETASeconds)), now, rev); err != nil { return fmt.Errorf("pgstore: apply status: %w", err) } if _, err := tx.Exec(ctx, `update books set revision = revision + 1 where id = $1`, bookID); err != nil { return fmt.Errorf("pgstore: bump book revision: %w", err) } return nil }) } // FinishRun closes a run and its attempt: the read-model status, the end of the attempt and what // systemd said about it. Money is NOT touched here — see Settlement. // // closed is false when the write did not apply, and the caller must then do nothing else: the run was // already finished, or — the case that made this a bool — the attempt it was asked to close is no // longer the run's live one. // // ⚠ That second guard is money. A sweep decides from a snapshot and writes seconds later; between the // two, the user can stop the run and RESUME it, and the resumed run is live again with a second // attempt holding a second reservation. The old attempt's exit marker is still on disk — nothing // deletes markers of attempts that ended — so the stale pass would close the run from it, leaving // attempt 2's hold in NO worklist: `ListLiveRuns` selects runs with `finished_at is null` and // `UnsettledRuns` attempts with `ended_at is not null`, and the resumed run matches neither once it // has been re-finished. Reachable only since a run can come back to life at all, which is this pack. func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, status, exitResult string, exitCode *int, now time.Time) (closed bool, err error) { if !validRunStatus(status) { return false, fmt.Errorf("pgstore: %q is not a run status", status) } err = s.inTx(ctx, func(tx pgx.Tx) error { // The BOOK's row is locked first, here and in the materializer. The two used to take them in // opposite orders — the materializer books-then-runs, this one runs-then-books — and two // reconcilers on one run (overlapping deploy generations) then deadlocked in both directions; // Postgres aborts one side, so it cost a failed sweep rather than corruption. Measured. 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 // the run is gone } return fmt.Errorf("pgstore: lock book: %w", err) } if err := tx.QueryRow(ctx, ` update runs set status = $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, status, now, attemptID).Scan(&bookID); err != nil { if errors.Is(err, pgx.ErrNoRows) { // Already finished, or asked about an attempt that is no longer the live one. Both are // "someone else got here first"; finishing is idempotent by refusal, not by repetition. return nil } return fmt.Errorf("pgstore: finish run: %w", err) } if _, err := tx.Exec(ctx, ` update run_attempts set ended_at = $2, exit_code = $3, exit_result = $4 where id = $1 and ended_at is null`, attemptID, now, exitCode, exitResult); err != nil { return fmt.Errorf("pgstore: finish attempt: %w", err) } if _, err := tx.Exec(ctx, ` update books set status = $2, revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID, status); err != nil { return fmt.Errorf("pgstore: finish book: %w", err) } closed = true return nil }) return closed, err } func validRunStatus(s string) bool { switch s { case "translating", "awaiting_bank", "finalizing", "ready", "stopped", "failed", "paused": return true } return false } // FinishUnspawnedStop closes a run that was stopped before its unit ever existed, and only if that is // still true when the write happens. // // The re-check is the whole method. The reconciler decides from a snapshot in which the attempt had // no unit; between that read and this write the queue worker can claim the attempt and create one, // and closing the run then leaves an engine spending against a book with no open reservation and no // list that looks at it — the reconciler lists by `finished_at is null` and settlement by an open // reservation, so it would be in neither. `ReleaseUnspawned` already guards the MONEY of exactly this // window under a lock (PD-159); this is the same guard for the lifecycle the money follows. // // finished is false when the attempt was spawned after all: the caller does nothing, and the next // sweep meets an ordinary live run — with a unit to signal and an intent that says to. func (s *Store) FinishUnspawnedStop(ctx context.Context, runID string, attemptID int64, now time.Time) (finished bool, err error) { err = s.inTx(ctx, func(tx pgx.Tx) error { // Book first, then the attempt: the order every transaction in this package takes (lockBook). 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 // the run is gone } return fmt.Errorf("pgstore: lock book: %w", err) } var unit *string var ended *time.Time if err := tx.QueryRow(ctx, `select unit_name, ended_at from run_attempts where id = $1 and run_id = $2 for update`, attemptID, runID).Scan(&unit, &ended); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return fmt.Errorf("pgstore: lock attempt: %w", err) } if unit != nil && *unit != "" { return nil // spawned inside the window; not this path's business any more } if ended != nil { // The attempt this pass is holding is over, which means the run moved on without it — a stop // and a RESUME can both have happened since the snapshot was taken, and the run is live // again on a second attempt holding a second reservation. Closing it from here would put // that hold in no worklist at all. The same guard FinishRun carries (PD-181); acceptance // found this path missing it. return nil } tag, err := tx.Exec(ctx, ` update runs set status = 'stopped', finished_at = $2, revision = (select revision + 1 from books where id = runs.book_id) where id = $1 and finished_at is null`, runID, now) if err != nil { return fmt.Errorf("pgstore: finish stopped run: %w", err) } if tag.RowsAffected() == 0 { return nil // already finished by an earlier pass } if _, err := tx.Exec(ctx, ` update run_attempts set ended_at = $2, exit_result = $3 where id = $1 and ended_at is null`, attemptID, now, StopRequestedResult); err != nil { return fmt.Errorf("pgstore: finish attempt: %w", err) } if _, err := tx.Exec(ctx, ` update books set status = 'stopped', revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID); err != nil { return fmt.Errorf("pgstore: finish book: %w", err) } finished = true return nil }) return finished, err } // StopRequestedResult is the exit_result of an attempt that ended by this platform's request without // systemd getting to write a marker. Deliberately a value systemd cannot produce: an operator reading // the column must be able to tell what the machine saw from what the platform concluded. const StopRequestedResult = "stop-requested" // MarkSettled records that the money of a run has been resolved. func (s *Store) MarkSettled(ctx context.Context, runID string, now time.Time) error { _, err := s.pool.Exec(ctx, `update runs set settled_at = $2 where id = $1 and settled_at is null`, runID, now) if err != nil { return fmt.Errorf("pgstore: mark settled: %w", err) } return nil } // UnsettledRuns lists finished runs whose money is still open. A run can finish and fail to settle // — the engine's committed figure is read from a process that has to be asked, and asking can fail — // and without this list that hold would stay reserved forever. func (s *Store) UnsettledRuns(ctx context.Context) ([]LiveRun, error) { // Keyed on the ATTEMPT being over, not on the RUN being over. An attempt that was interrupted and // replaced leaves its reservation open while its run goes on, and a list that filtered on the run // never looked at it again — the hold stayed reserved for the life of the account. return s.queryRuns(ctx, ` join run_attempts a on a.run_id = r.id and a.ended_at is not null join reservations res on res.engine_run_id = r.id || '#' || a.attempt_no and res.state = 'open' order by a.ended_at`) } // ReservationKey is the attempt's reservation id, exported so the reconciler can settle without // re-deriving a format that lives in this package. func ReservationKey(runID string, attempt int) string { return engineRunKey(runID, attempt) }