textmachine/platform/internal/pgstore/sink.go

541 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
//
// ⚠ It is a LEGACY path now: since the platform names an attempt's stream when the attempt row is
// created (pgstore.EngineStreamID), the tailer is always given the id it wants and never adopts a
// handshake, so this is reached only for attempts written by an older build. What it used to do
// besides the binding — recording the chunker version — moved into `effect`, where the handshake
// arrives as an ordinary event.
//
// 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.TypeHello:
// The handshake reaches here as an ordinary event — it is seq 1 and it moves the cursor — and
// what it changes is one fact: WHICH cut of the source the engine is working from. A reader
// that persisted a chapter manifest cut by a different chunker would be joining on ordinals
// that were silently re-numbered.
//
// ⚠ It used to be recorded in Begin, and Begin is now unreachable for any attempt this build
// created: the platform names the stream when the attempt row is written, so the tailer never
// has to adopt a handshake. Moving the effect here is what kept it from being silently lost —
// found by the adversarial review of this pack.
var h ingest.Hello
if err := decode(ev, &h); err != nil {
return err
}
if h.ChunkerVersion == "" {
return nil
}
if _, err := tx.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
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, ev, 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). WHICH ceiling decides the reason — and, one
// step later, whether a resume can do anything about it (see CeilingPause).
return r.bump(ctx, tx, `
update runs set status = 'paused', paused_reason = $2, revision = $3
where id = $1`, r.runID, CeilingPause(c.Scope))
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 records one resolved output unit and re-derives the counters of its chapter.
//
// ⚠ The fold is an ASSIGNMENT and not an increment, and that is ratified rather than stylistic
// (D39.131 п.2г). Delivery on this seam is at-least-once, so absorbing a duplicate is the
// CONSUMER's duty: `+ 1` per line counted a re-read line twice, and re-reading is the ordinary
// consequence of a cursor that was written before its effect — or of the emitter's own named
// residual, a line committed to the outbox that never reached the file and is therefore announced
// again by the next process. Recording the (chapter, unit, wave) identity the event carries and
// counting the set makes the answer the same however many times the line arrives, and it self-heals
// the undrained tail rather than merely tolerating it.
//
// Unknown waves are dropped rather than stored: `wave` is a closed vocabulary on both sides of the
// seam, and a value from outside it would land in a column whose constraint refuses it and take the
// whole materialization down with it. Under the minor-version rule an unknown value is ignored.
//
// ⚠ Bounded by what exists, unchanged: without the engine's persisted chapter manifest (unified
// backlog row 100) there is no `chapters` row to carry the derived counters, so the fold records the
// unit and stops there. The counters the screen reads today come from the progress event.
func (r *RunSink) unitDone(ctx context.Context, tx pgx.Tx, ev ingest.Envelope, u ingest.UnitDone) error {
if u.Wave != ingest.WaveDraft && u.Wave != ingest.WaveEdit {
return nil
}
// The LATEST resolution wins, and "latest" is by the event's own timestamp rather than by arrival.
// A unit can legitimately be resolved twice with different dispositions — a redrive re-attacks a
// flagged one — and the read model wants the second.
//
// ⚠ The `where` guards against ARRIVAL ORDER, and it is worth being exact about what it does not
// do, because the acceptance found this comment claiming the opposite: the engine does not emit
// an inversion. Every announcement carries the clock of the process making it (pipeline/events.go,
// `at := e.now()`), the re-announcement of a line a dead process never got onto the file included
// — such a row carries no key, ForgetEvents drops it, and the next process announces it afresh
// with its own stamp (store/outbox.go). So this is not the repair of a known regression.
//
// What it buys is that the fold's result stops depending on WHO WROTE LAST. The seam is
// at-least-once (D39.119 п.3) and this consumer assigns rather than increments, so a disposition
// is otherwise decided by delivery order — which is a property of the tailer, not of the data. One
// predicate makes it a property of the data, and a redrive that moved a unit from flagged to
// shipped cannot be walked backwards by a re-read.
if _, err := tx.Exec(ctx, `
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, reason, at)
values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (book_id, chapter, unit, wave)
do update set shipped = excluded.shipped, flagged = excluded.flagged,
reason = excluded.reason, at = excluded.at
where excluded.at >= unit_resolutions.at`,
r.bookID, u.Chapter, u.Unit, u.Wave, u.Shipped, u.Flagged, u.Reason, ev.Time); err != nil {
return fmt.Errorf("pgstore: record unit: %w", err)
}
// units_done stays what it has always been — the count of the EDIT wave, the aggregate the
// resync path can restore (00002, K-10 is open). Derived from the same set as the two phase
// counters so the three can never disagree.
tag, err := tx.Exec(ctx, `
update chapters c
set units_draft_done = (select count(*) from unit_resolutions
where book_id = c.book_id and chapter = c.number and wave = 'draft'),
units_edit_done = (select count(*) from unit_resolutions
where book_id = c.book_id and chapter = c.number and wave = 'edit'),
units_done = (select count(*) from unit_resolutions
where book_id = c.book_id and chapter = c.number and wave = 'edit'),
revision = (select revision + 1 from books where id = $1)
where c.book_id = $1 and c.number = $2`, r.bookID, u.Chapter)
if err != nil {
return fmt.Errorf("pgstore: fold unit: %w", err)
}
if tag.RowsAffected() == 0 {
// No manifest yet — see the note above. The unit itself IS recorded, so the counters come out
// exact the moment a chapter row exists; the book's revision is deliberately NOT bumped,
// because nothing a client can read has changed and every bump costs one refetch.
return nil
}
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
})
}
// RunEnding is one attempt's end, as the reconciler decided it.
type RunEnding struct {
RunID string
AttemptID int64
// Status is the run's product status. PausedReason accompanies `paused` and must be empty for
// every other status: a run that is not paused carrying a reason it is paused would be read by
// the resume path, which asks that column what to do next.
Status string
PausedReason string
// ExitResult is systemd's $SERVICE_RESULT and ExitCode the engine's own code, when it exited.
ExitResult string
ExitCode *int
Now time.Time
}
// 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.
//
// ⚠ The paused reason is written HERE and not only by PauseRun, and that is the ceiling half of
// PD-113. A ceiling halt reaches this platform twice — as a stream event and as exit code 4 — and
// the two fail independently: a journal that could not be written still leaves the code. On that
// path there is no event to have set the reason, and a `paused` run with a null reason gives the
// screen nothing to say and the resume path nothing to judge.
//
// 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, in RunEnding) (closed bool, err error) {
if !validRunStatus(in.Status) {
return false, fmt.Errorf("pgstore: %q is not a run status", in.Status)
}
switch {
case in.PausedReason != "" && in.Status != "paused":
return false, fmt.Errorf("pgstore: a %s run cannot carry a paused reason", in.Status)
case in.PausedReason != "" && !validPauseReason(in.PausedReason):
return false, fmt.Errorf("pgstore: %q is not a pause reason", in.PausedReason)
}
runID, attemptID, now := in.RunID, in.AttemptID, in.Now
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)
}
// nullif: a run that ends in any other status must not keep the reason a PREVIOUS pause of it
// left behind — the resume path reads that column to decide what it may do.
if err := tx.QueryRow(ctx, `
update runs set status = $2, paused_reason = nullif($5, ''), 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, in.Status, now, attemptID, in.PausedReason).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, in.ExitCode, in.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, in.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) }