436 lines
19 KiB
Go
436 lines
19 KiB
Go
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) }
|