textmachine/backend/internal/pipeline/events.go

577 lines
25 KiB
Go

package pipeline
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
"sync"
"sync/atomic"
"time"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/runevents"
"textmachine/backend/internal/store"
)
// events.go: WHEN the run-event seam emits (row 103). The form lives in internal/runevents, the durable
// sequencing in internal/store (events_outbox); here is the driver's half — the call sites, the counters
// and the policy for a journal that cannot be written.
//
// ORDER, and it is the invariant: a fact is committed to SQLite, THEN its line is enqueued in the
// outbox, THEN the outbox is projected onto the file. Nothing is ever written to the journal that the
// database does not already hold, which is the direction that would cost money and lie to a user; the
// opposite direction — a fact committed whose line was lost to a crash — is bounded and named per event
// below, and its ratified repair channel is the `status --json` resync (D39.106 §2).
//
// The two events a lost line cannot hurt are `spend` (cumulative: the next line carries the total again)
// and `progress` (an assignment: the next line carries the counters again). The one it can is
// `unit_done`, which a reader FOLDS BY INCREMENT — see unitResolved.
// emitter writes one process's region of a book's event journal.
//
// Everything is under one mutex, including the projection: the waves emit from N workers, and the reader
// cannot survive seq N+1 appearing above seq N. Under the mutex the projection always reads the outbox
// in sequence order, so it appends a dense prefix even when two workers commit concurrently (the write
// pool is single-connection, so a transaction sees MAX(seq)=N only after N's transaction committed).
type emitter struct {
mu sync.Mutex
store *store.Store
journal *runevents.Journal
log *slog.Logger
runID string
bookID string
now func() time.Time
lastSeen int64 // the highest seq already on the file
degraded bool // the journal refused a write; the run continues (PD-60)
// marks are the announce-once keys whose line has been enqueued but not yet confirmed on the file.
marks map[int64]string
// waves is the live per-phase counter. nil until the driver knows the book's cut (beginWaves).
waves *waveCounters
}
// openEmitter opens the book's journal, drops the previous processes' outbox rows and writes this
// process's handshake as its first line. runID is the process identity (the trace id) and is the half of
// the ratified idempotency key (engine_run_id, seq) that makes a resumed run a new stream in the same
// file rather than a corruption of the old one.
func openEmitter(st *store.Store, dir, runID, bookID string, log *slog.Logger) (*emitter, error) {
j, err := runevents.OpenJournal(dir)
if err != nil {
return nil, err
}
// The stream identity must be FRESH, and since row 102 the caller can supply it — so this is the one
// place that checks. Reusing an id that already wrote for this book is not a small mistake: the
// sequence continues from the previous process's numbers instead of restarting at 1, and the
// projection, whose cursor starts at zero, re-appends that entire previous stream before writing a
// handshake at some seq far past 1. A reader then re-applies every counting event it already applied
// and finally refuses the handshake. Measured on the real binary before this guard: two runs under
// one TM_TRACE_ID produced 33 lines, 15 of them byte-identical duplicates, and a hello at seq 16.
//
// The run is NOT stopped for it — a paid book must not die because a caller mislabelled it — so the
// stream takes a fresh identity and says so at ERROR. The caller's id keeps naming the run in the
// logs; only the seam's key changes.
if used, err := st.EventsUsed(runID); err != nil {
j.Close()
return nil, err
} else if used {
fresh := obs.NewTraceID()
log.Error("this run was given an id that has already written run events for this book; a stream identity must be unique per process, so the event stream announces itself under a fresh one (fix the caller: TM_TRACE_ID must be unique per invocation)",
"given", runID, "stream_id", fresh)
runID = fresh
}
if err := st.ForgetEvents(runID); err != nil {
j.Close()
return nil, err
}
e := &emitter{store: st, journal: j, log: log, runID: runID, bookID: bookID, now: time.Now, marks: map[int64]string{}}
e.emit(runevents.TypeHello, runevents.Hello{
StreamVersion: runevents.StreamVersion,
EngineRunID: runID,
BookID: bookID,
ChunkerVersion: chunkerVersion,
})
return e, nil
}
func (e *emitter) close() error {
if e == nil {
return nil
}
return e.journal.Close()
}
// emit enqueues one event and projects the journal. It never returns an error, and that is the answer to
// PD-60 ("what does the engine do when the journal cannot be written"), chosen explicitly because the
// row says both positions are legitimate and a silent third is not:
//
// the engine DEGRADES LOUDLY and keeps running. A paid book must not die because a freshness side-channel
// cannot be written — the money protection is the platform's hold and the engine's own ceiling, neither
// of which depends on this file (D39.106 §2) — and the two facts a reader most needs, the ceiling halt
// and the graceful stop, also travel as distinct exit codes, so they survive a journal that never opened.
// Nothing is dropped: the lines stay in the outbox, every later event retries the whole pending prefix,
// so a transient ENOSPC or EIO heals itself and the journal ends up complete and gap-free. If it never
// heals, the reader's ratified fallback is the `status --json` resync.
//
// The rejected alternative is to BLOCK or abort the run on a journal failure. It is legitimate — it keeps
// the projection exact — but it makes the availability of a status channel a precondition for translating
// a book that has already been paid for, and on the realistic failure (a full disk) the ledger's own
// writes are failing at the same moment and the run stops anyway, one layer lower and with a better error.
func (e *emitter) emit(t runevents.Type, data any) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.enqueue(t, data)
e.project()
}
func (e *emitter) enqueue(t runevents.Type, data any) {
at := e.now()
if err := e.store.EnqueueEvent(e.runID, func(seq int64) ([]byte, error) {
return runevents.Line(seq, t, at, data)
}); err != nil {
e.log.Error("run-event outbox write failed; this event will not reach the platform's stream (the run continues; the resync channel is `tmctl status --json`)",
"event", string(t), "err", err)
}
}
// project appends every stored line the file has not got yet, in sequence order, and only THEN records
// which announce-once events a reader has now seen. The order is the point: a line still in the outbox
// when a process dies has no ledger row, so the next process announces its unit again instead of
// assuming a reader was told.
func (e *emitter) project() {
caught := 0
for {
lines, err := e.store.PendingEvents(e.runID, e.lastSeen, projectBatch)
if err != nil {
e.degrade("read the pending run events", err)
return
}
for _, l := range lines {
if err := e.journal.Append(l.Line); err != nil {
e.degrade("append to the run-event journal", err)
e.markAnnounced() // whatever DID land is still announced
return
}
e.lastSeen = l.Seq
// Per line, not per batch: what is left unmarked is what a crash makes a later process announce
// a second time, so the window is kept to a single transaction rather than the rest of the batch.
e.markAnnounced()
}
caught += len(lines)
if len(lines) < projectBatch {
break // drained: a short batch means there was nothing more to read
}
}
if e.degraded && caught > 0 {
e.degraded = false
e.log.Warn("the run-event journal is writable again; the pending events were caught up",
"caught_up", caught, "last_seq", e.lastSeen)
}
e.markAnnounced()
}
// projectBatch bounds one read of the outbox. It matters only on the failure path: while the journal
// refuses writes the cursor cannot advance, so the unprojected prefix grows with every event and an
// unbounded read would re-materialize all of it on each one, under this mutex. Large enough that a
// healthy run always drains in one read, small enough that a degraded one costs a constant.
const projectBatch = 256
// markAnnounced records the announce-once events whose line is now on the file.
func (e *emitter) markAnnounced() {
if len(e.marks) == 0 {
return
}
landed := map[int64]string{}
for seq, key := range e.marks {
if seq <= e.lastSeen {
landed[seq] = key
}
}
if len(landed) == 0 {
return
}
if err := e.store.MarkAnnounced(e.runID, landed); err != nil {
// Not fatal and not silent: the cost of losing this write is that a later process announces those
// units a second time, which an at-least-once reader is required to absorb.
e.log.Error("could not record which run events have been delivered; a later run may announce them again",
"events", len(landed), "err", err)
return
}
for seq := range landed {
delete(e.marks, seq)
}
}
// degrade reports the FIRST failure of a run of them and stays quiet afterwards: a full disk fails on
// every event, and a per-event ERROR line would bury the run's real output.
func (e *emitter) degrade(what string, err error) {
if e.degraded {
return
}
e.degraded = true
e.log.Error("could not "+what+"; the platform's live stream stalls here and resumes if this heals (the run continues — money is protected by the hold and the ceiling, not by this file; resync channel: `tmctl status --json`)",
"last_seq_on_file", e.lastSeen, "err", err)
}
// spendLine is the money event, handed to the ledger so it rides the SAME transaction that settles the
// money (D39.106 §2 "the outbox row is written in the transaction of the checkpoint"). It is the one
// event for which that matters: a journal line claiming spend the ledger never booked is a divergence
// about money, not about a progress bar.
//
// It never fails the settle. A render failure returns "no event" rather than an error, because the
// alternative is a rolled-back settle for a call the provider has already billed — losing a paid
// checkpoint to protect an indicator is the wrong way round.
func (e *emitter) spendLine() *store.SpendLine {
if e == nil {
return nil
}
at := e.now()
return &store.SpendLine{RunID: e.runID, Line: func(seq int64, committedUSD float64) ([]byte, error) {
line, err := runevents.Line(seq, runevents.TypeSpend, at,
runevents.Spend{CommittedMicroUSD: runevents.MicroUSD(committedUSD)})
if err != nil {
e.log.Error("could not render the spend event; the settle proceeds without it (the counter is cumulative — the next one carries the total again)", "err", err)
return nil, nil
}
return line, nil
}}
}
// flush projects whatever the ledger enqueued inside its own transactions (the spend event). Called
// after a settle, so money freshness does not wait for the unit to finish.
func (e *emitter) flush() {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.project()
}
// waveCounters is the live per-phase progress, in OUTPUT UNITS — the granularity every engine read model
// counts in, so the stream and the `status --json` resync fold into one column on the same scale.
//
// `counted` starts as the units ALREADY resolved when this process opened the book, which is what makes
// the counter book-state rather than process-state: a resumed run walks every unit again at $0, and a
// counter that started at zero would walk a reader's progress bar backwards on every resume.
type waveCounters struct {
draft, edit runevents.Counter
counted map[unitWave]bool
// since/resolved are this PROCESS's throughput, which is what the stream's ETA is derived from: the
// wall clock since the run began working, and how many units it has resolved in it. A resumed run
// that replays everything at $0 resolves nothing and therefore offers no estimate, which is correct
// — it has measured nothing.
since time.Time
resolved int
}
// eta is the seconds remaining at the rate this run has actually achieved, or 0 when nothing has been
// measured yet (the reader renders no estimate rather than "0 s left").
func (w *waveCounters) eta(now time.Time) int {
left := (w.draft.Total - w.draft.Done) + (w.edit.Total - w.edit.Done)
if w.resolved == 0 || left <= 0 {
return 0
}
eta := int(now.Sub(w.since).Seconds() / float64(w.resolved) * float64(left))
if eta < 1 {
// Work remains, so the honest floor is a second: the field is optional in the wire form, and an
// omitted one does not leave the reader's column alone — it NULLS it (see runevents.Progress).
return 1
}
return eta
}
type unitWave struct {
wave string
chapter int
unit int
}
// beginWaves seeds the counters from the store and announces them. Called once, when the driver knows
// the book's cut, before the first wave.
func (e *emitter) beginWaves(units []editUnit, shape waveShape, stored map[chunkKey][]store.ChunkStatus) {
if e == nil {
return
}
w := &waveCounters{counted: map[unitWave]bool{}, since: e.now()}
if shape.nDraft > 0 {
w.draft.Total = len(units)
}
if shape.nEdit > 0 {
w.edit.Total = len(units)
}
for _, u := range units {
draft, edit := shape.resolved(u, unitRows(u, stored))
if draft {
w.counted[unitWave{runevents.WaveDraft, u.Chapter, u.FirstChunkIdx}] = true
w.draft.Done++
}
if edit {
w.counted[unitWave{runevents.WaveEdit, u.Chapter, u.FirstChunkIdx}] = true
w.edit.Done++
}
}
e.mu.Lock()
e.waves = w
e.mu.Unlock()
// No estimate yet: nothing has been measured, and inventing one from a previous run would be a
// number about work this process has not done.
e.emit(runevents.TypeProgress, runevents.Progress{Draft: w.draft, Edit: w.edit})
}
// unitResolved announces one output unit a WAVE has just finished with, and the counters it moved.
//
// Two different questions are asked here, and conflating them is what a first version got wrong:
//
// - HAS A READER BEEN TOLD? — answered by the announce-once ledger in the outbox, because a reader
// folds `unit_done` by INCREMENT and this is the vocabulary's only counting event. Re-announcing a
// unit a resumed run merely replayed at $0 would add a whole book to a chapter counter on every
// resume; NOT announcing one whose process died between the disposition and the insert would leave
// that chapter short forever. Only a durable ledger answers both, and it is why the once-rows
// outlive their run.
// - DOES THE COUNTER MOVE? — answered by what was already resolved when this process opened the book.
// The counters are BOOK state, not process state: a resumed run re-walks every finished unit, and a
// counter that started at zero would walk a reader's progress bar backwards.
//
// The two disagree exactly in the interesting cases, and each is then right on its own terms: a unit
// whose announcement was lost to a crash is re-announced but not re-counted, and a book translated
// before this ledger existed is announced for the first time without its counters jumping.
//
// ⚠ Residual, and it is the irreducible one: between the outbox commit below and the line reaching the
// file there is no transaction, so a process killed between those two adjacent statements loses that
// announcement for good. The class ENDS at the fold, not here — `unit_done` carries a stable
// (chapter, unit, wave) identity, so a reader that assigns instead of incrementing is exact under any
// delivery, which is what at-least-once delivery (D39.119 п.3) has always asked of a consumer. Raised
// as a diff with the pack.
func (e *emitter) unitResolved(wave string, chapter, unit int, shipped, flagged bool, reason string) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
at := e.now()
key := unitWave{wave, chapter, unit}
// The counters may be unavailable (their baseline read failed, beginRunEvents) — the ANNOUNCEMENT is
// not: it needs no baseline, a reader folds it per chapter, and dropping it would leave that chapter
// short forever. Only the progress line is skipped in that case.
w := e.waves
counts := false
if w != nil {
if counts = !w.counted[key]; counts {
w.counted[key] = true
if wave == runevents.WaveEdit {
w.edit.Done++
} else {
w.draft.Done++
}
w.resolved++
}
}
onceKey := e.unitOnceKey(key)
announcing, seq, err := e.store.EnqueueOnce(e.runID, onceKey, func(seq int64) ([]byte, error) {
return runevents.Line(seq, runevents.TypeUnitDone, at, runevents.UnitDone{
Chapter: chapter, Unit: unit, Wave: wave, Shipped: shipped, Flagged: flagged, Reason: reason,
})
})
if err != nil {
e.log.Error("run-event outbox write failed; this unit will not reach the platform's stream (the run continues; the resync channel is `tmctl status --json`)",
"event", string(runevents.TypeUnitDone), "wave", wave, "chapter", chapter, "unit", unit, "err", err)
}
if announcing {
e.marks[seq] = onceKey
}
if counts {
e.enqueue(runevents.TypeProgress, runevents.Progress{Draft: w.draft, Edit: w.edit, ETASeconds: w.eta(at)})
}
if announcing || counts {
e.project()
}
}
// unitOnceKey is the identity of one announcement. It carries the BOOK because the ledger lives in a
// project database and `project_db` may be shared by two books: without it the second book's units would
// collide with the first's keys and never be announced at all.
func (e *emitter) unitOnceKey(k unitWave) string {
return fmt.Sprintf("unit:%s:%s:%d:%d", e.bookID, k.wave, k.chapter, k.unit)
}
// terminal writes the stream's last line — and, on a ceiling halt, the `ceiling` event before it.
//
// The ceiling is the whole of PD-113: the engine returns the stop as an ERROR, every unrecognised error
// maps to exit 1, and the platform then records `failed` for a stop its own contract says is `paused`.
// It travels twice by design — as this event and as its own exit code — because the two channels fail
// independently: a journal that could not be written still leaves the code, and a process killed before
// it exited still leaves the event.
func (e *emitter) terminal(res *BookResult, err error) {
if e == nil {
return
}
var halt *CeilingHalt
var sigStop *WaveSignatureStop
var refusal *Refusal
switch {
case errors.As(err, &refusal):
// A refusal did no work, so the stream gets no verdict about work. Writing `failed` here would
// have the exit code say "refused, nothing happened" while the stream said the run failed.
return
case errors.As(err, &halt):
e.emit(runevents.TypeCeiling, runevents.Ceiling{Halted: true, Scope: halt.Scope})
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeCeiling})
case errors.As(err, &sigStop):
// The bank_stop event itself was written where the stop happened, with its term count.
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeBankStop})
case errors.Is(err, context.Canceled):
// In tmctl the run context is cancelled by SIGINT/SIGTERM and by nothing else (main.go's
// signal.NotifyContext), so this is the graceful stop, not a crash.
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeStopped})
case err != nil:
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeFailed})
case res != nil && res.Flagged > 0:
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeFlagged})
default:
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeClean})
}
}
// openEvents starts this process's region of the book's journal, once per runner.
//
// A run without a trace id on its context is not an invocation the seam describes — the id IS the
// engine_run_id, half of the ratified idempotency key, and a reader refuses an empty one rather than
// collapsing every run into one namespace. tmctl always opens a traced context (main.go); an in-process
// caller that does not is driving the pipeline, not running a job, and writes no journal.
func (r *Runner) openEvents(ctx context.Context) {
if r.events != nil {
return
}
ri, ok := obs.ReqInfoFromContext(ctx)
if !ok || ri.TraceID == "" {
return
}
e, err := openEmitter(r.Store, r.journalDir(), ri.TraceID, r.Book.BookID, r.Log)
if err != nil {
// A journal that cannot even be OPENED is reported and not fatal, by the same policy emit() is:
// the run is paid for, the money is protected by the hold and the ceiling, and the reader's
// fallback channel is the `status --json` resync. Nothing here returns an error, on purpose — an
// error return would be a way for observability to stop a run, and there must not be one.
r.Log.ErrorContext(ctx, "could not open the run-event journal; the platform's live stream will be silent for this run (the run continues; resync channel: `tmctl status --json`)",
"dir", r.journalDir(), "err", err)
return
}
r.events = e
}
// beginRunEvents seeds the live per-phase counters from what the store already holds. It reads the
// stored rows once — the same read `status` makes — so the baseline is the BOOK's state and not this
// process's: a resumed run walks every finished unit again at $0, and counters that started at zero
// would walk a reader's progress bar backwards on every resume.
// It degrades rather than aborting, by the same policy emit() follows: a read taken for a progress
// indicator must not be what kills a paid run. Without the baseline the counters are simply not
// published — every other event still is.
func (r *Runner) beginRunEvents(ctx context.Context, chunks []chunk.Chunk) {
if r.events == nil {
return
}
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
r.Log.ErrorContext(ctx, "could not read the stored dispositions for the run-event counters; this run publishes no progress (the run continues; resync channel: `tmctl status --json`)", "err", err)
return
}
byChunk := map[chunkKey][]store.ChunkStatus{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
r.events.beginWaves(r.outputUnits(chunks), r.waveShape(), byChunk)
}
// draftUnitTracker answers "was that the LAST member of its unit?" for the draft wave, which fans out
// over chunks while the seam counts output units.
type draftUnitTracker struct {
units []editUnit
of map[chunkKey]int // member chunk → its unit's index
at map[chunkKey]int // member chunk → its index in the wave's result slice
left []atomic.Int32
}
func newDraftUnitTracker(units []editUnit, chunks []chunk.Chunk) *draftUnitTracker {
t := &draftUnitTracker{
units: units,
of: make(map[chunkKey]int, len(chunks)),
at: make(map[chunkKey]int, len(chunks)),
left: make([]atomic.Int32, len(units)),
}
for i, ch := range chunks {
t.at[chunkKey{ch.Chapter, ch.ChunkIdx}] = i
}
for i, u := range units {
t.left[i].Store(int32(len(u.Members)))
for _, m := range u.Members {
t.of[chunkKey{m.Chapter, m.ChunkIdx}] = i
}
}
return t
}
// memberDone records one member's completion and reports the unit when it was the last one. The atomic
// is also the synchronisation point for the results slice the caller then reads: each worker writes its
// own slot BEFORE decrementing, so the worker that sees zero sees every sibling's write.
func (t *draftUnitTracker) memberDone(ch chunk.Chunk) (editUnit, bool) {
i, ok := t.of[chunkKey{ch.Chapter, ch.ChunkIdx}]
if !ok {
return editUnit{}, false
}
if t.left[i].Add(-1) != 0 {
return editUnit{}, false
}
return t.units[i], true
}
// outcome folds a unit's member drafts into the pair the stream carries. The member→result index is the
// tracker's own and is built ONCE: rebuilding it per completed unit is quadratic in the book. SHIPPED means the wave
// left something the next one can work on (or, in a draft-only pipeline, something that exports);
// FLAGGED means at least one member needs a human, which is the c-lite rule the read models already use
// — a unit can legally be both.
func (t *draftUnitTracker) outcome(u editUnit, results []stageSeqResult) (shipped, flagged bool, reason string) {
for _, m := range u.Members {
i, ok := t.at[chunkKey{m.Chapter, m.ChunkIdx}]
if !ok {
continue
}
d := results[i]
if d.flagged {
if !flagged {
flagged, reason = true, string(d.flagReason)
}
shipped = shipped || d.recovered != "" // a cosmetic strip still exports its cleaned remainder
continue
}
shipped = shipped || d.finalText != ""
}
return shipped, flagged, reason
}
// journalDir is where this book's journal lives: the directory of book.yaml — the BOOK's directory
// (D39.106 §2), which is also the working directory the platform spawns the run in and tails. It is
// deliberately NOT the project DB's directory: `project_db` may point anywhere, and the seam is anchored
// to the config the caller passed.
func (r *Runner) journalDir() string {
if r.Book.Dir != "" {
return r.Book.Dir
}
return filepath.Dir(r.Book.ProjectDB)
}
// ingestSource reads and normalizes the book's source, classifying a failure as the refusal it is: this
// is the one place where "the text we were handed is unusable" is distinguishable from "the operator's
// config or host is broken", and an automated intake acts on the user's upload from that distinction.
func (r *Runner) ingestSource() (*chunk.Document, error) {
doc, err := chunk.IngestEncoded(r.Book.SourceFile, r.Book.Encoding, r.Book.SourceLang)
if err != nil {
return nil, refuseSource(err)
}
return doc, nil
}