332 lines
16 KiB
Go
332 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"textmachine/platform/internal/runner"
|
|
)
|
|
|
|
// seedALiveRun writes the smallest world the runs listing reads: an account, a book, a live run with
|
|
// a live attempt, and the hold against it. Written as SQL because the subject is the LISTING, and
|
|
// going through the service would drag the whole runner in to prove a table.
|
|
func seedALiveRun(t *testing.T, dsn string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
execSQL(t, dsn, `insert into users (id, email) values ('u1','u1@example.org')`)
|
|
execSQL(t, dsn, `insert into books (id, owner_id, title, source_lang, target_lang, status,
|
|
chapter_count, workdir, engine_book_id, added_at, revision)
|
|
values ('bk1','u1','蛊真人','zh','ru','translating',10,'/srv/books/bk1','bk1',$1,1)`, now)
|
|
execSQL(t, dsn, `insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at, revision)
|
|
values ('run_probe','bk1','translating',false,10,$1,1)`, now)
|
|
execSQL(t, dsn, `insert into run_attempts (run_id, attempt_no, started_at, last_offset, engine_run_id)
|
|
values ('run_probe',1,$1,0,'tm-stream-run_probe-1')`, now)
|
|
}
|
|
|
|
func execSQL(t *testing.T, dsn, sql string, args ...any) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
conn, err := pgx.Connect(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close(ctx)
|
|
if _, err := conn.Exec(ctx, sql, args...); err != nil {
|
|
t.Fatalf("%s: %v", sql, err)
|
|
}
|
|
}
|
|
|
|
// exit-marker runs INSIDE a run's transient unit, as ExecStopPost, where there is no database and no
|
|
// credential to open one with. Reaching for the DSN there would fail every marker in production and
|
|
// lose the only record of how the run ended — so the dispatch has to answer this command before it
|
|
// reads a secret. Pinned here because the dispatch order is invisible at the call site.
|
|
func TestTheExitMarkerCommandNeedsNoDatabase(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_DSN", "")
|
|
t.Setenv("TM_PLATFORM_DSN_FILE", "")
|
|
t.Setenv("SERVICE_RESULT", "oom-kill")
|
|
t.Setenv("EXIT_CODE", "killed")
|
|
t.Setenv("EXIT_STATUS", "TERM")
|
|
path := filepath.Join(t.TempDir(), "runs", "run_1-1.exit")
|
|
|
|
if err := run([]string{"exit-marker", path, "tm-run-run_1-1"}, os.Stdout); err != nil {
|
|
t.Fatalf("exit-marker with no DSN: %v", err)
|
|
}
|
|
m, err := runner.ReadMarker(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.Result != "oom-kill" || m.Code != "killed" || m.Status != "TERM" || m.Unit != "tm-run-run_1-1" {
|
|
t.Errorf("marker: %+v", m)
|
|
}
|
|
// Every other command still needs one, or it would silently do nothing.
|
|
if err := run([]string{"balance", "--user", "u1"}, os.Stdout); err == nil {
|
|
t.Error("a database command ran without a DSN")
|
|
}
|
|
}
|
|
|
|
func TestTheExitMarkerCommandNeedsBothArguments(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_DSN", "")
|
|
for _, args := range [][]string{{"exit-marker"}, {"exit-marker", "only-a-path"}} {
|
|
if err := run(append([]string{}, args...), os.Stdout); err == nil {
|
|
t.Errorf("%v was accepted", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The development intake is what puts a book in the read model until the contract's upload exists.
|
|
// It refuses a directory that is not an engine project, because the alternative is a run that dies
|
|
// at argument parsing inside a transient unit, where the only trace is "exit-code 1".
|
|
func TestAddingABookRefusesADirectoryTheEngineCannotUse(t *testing.T) {
|
|
dir := t.TempDir()
|
|
args := []string{"--user", "u1", "--workdir", dir, "--title", "b",
|
|
"--source-lang", "zh", "--target-lang", "ru", "--chapters", "10", "--characters", "23000000"}
|
|
_, err := parseBookIntake(args)
|
|
if err == nil || !strings.Contains(err.Error(), "engine project directory") {
|
|
t.Fatalf("a directory without book.yaml gave %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, runner.ConfigFile), []byte("book_id: x\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := parseBookIntake(args)
|
|
if err != nil {
|
|
t.Fatalf("a valid project directory was refused: %v", err)
|
|
}
|
|
if got.ChapterCount != 10 || got.CharacterCount != 23_000_000 || got.OwnerID != "u1" {
|
|
t.Errorf("intake: %+v", got)
|
|
}
|
|
if !filepath.IsAbs(got.Workdir) {
|
|
t.Errorf("the workdir was not made absolute: %q — the unit runs with its own working directory", got.Workdir)
|
|
}
|
|
}
|
|
|
|
// Chapters are what bound the run-ceiling scale, so a book registered without them would offer a
|
|
// scale of zero and no run could ever start on it.
|
|
func TestAddingABookRequiresTheFactsTheScaleIsBuiltFrom(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, runner.ConfigFile), []byte("book_id: x\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
full := []string{"--user", "u1", "--workdir", dir, "--title", "b",
|
|
"--source-lang", "zh", "--target-lang", "ru", "--chapters", "10"}
|
|
drop := func(flag string) []string {
|
|
out := []string{}
|
|
for i := 0; i < len(full); i += 2 {
|
|
if full[i] != flag {
|
|
out = append(out, full[i], full[i+1])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
for _, flag := range []string{"--user", "--workdir", "--title", "--source-lang", "--target-lang", "--chapters"} {
|
|
if _, err := parseBookIntake(drop(flag)); err == nil {
|
|
t.Errorf("a book without %s was accepted", flag)
|
|
}
|
|
}
|
|
}
|
|
|
|
// `tmplatformctl runs` shows LIVE runs, and `--stalled` narrows that to the ones the reconciler
|
|
// keeps failing on. Both halves are what the usage line promises, and the first half was missing:
|
|
// the floor was one failure, so on a healthy deployment the command answered "no run is failing to
|
|
// reconcile" and never showed the runs that were going perfectly well — the same answer it gives
|
|
// while a run IS wedged and the deferral has not counted it yet.
|
|
//
|
|
// Mutation caught: raising the plain form's floor above zero.
|
|
func TestTheRunsListingShowsLiveRunsAndNarrowsToStalledOnes(t *testing.T) {
|
|
dsn := freshDB(t)
|
|
t.Setenv("TM_PLATFORM_DSN", dsn)
|
|
seedALiveRun(t, dsn)
|
|
|
|
if got := capture(t, "runs"); !strings.Contains(got, "run_probe") {
|
|
t.Errorf("the plain listing does not show a live run:\n%s", got)
|
|
}
|
|
if got := capture(t, "runs", "--stalled"); strings.Contains(got, "run_probe") {
|
|
t.Errorf("a run with no failures is reported as stalled:\n%s", got)
|
|
}
|
|
// …and once it has failed enough, the narrowed form names it.
|
|
execSQL(t, dsn, `update run_attempts set reconcile_failures = 99`)
|
|
if got := capture(t, "runs", "--stalled"); !strings.Contains(got, "run_probe") {
|
|
t.Errorf("a run past the threshold is missing from --stalled:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// The table stays a table, and the figures in it are the ones an operator may act on.
|
|
//
|
|
// Both columns it prints from the database are hostile: the engine's stderr arrives verbatim and
|
|
// multi-byte (the product translates zh/ja), and the TITLE is what the uploader typed — the intake
|
|
// strips control characters only from titles taken from a filename. A tab forges a column, a newline
|
|
// forges a row, and a byte-slice through a three-byte rune leaves the tail unreadable.
|
|
//
|
|
// The SPENT column is the money half: `spend_micro_usd` holds the engine's BOOK-lifetime figure, so
|
|
// the attempt's own cost is the difference from its baseline — and with no baseline there is no
|
|
// honest figure at all, which is what `settle` itself concludes.
|
|
//
|
|
// Mutation caught: cutting by byte in oneLine; dropping the control-character map; printing
|
|
// spend_micro_usd raw; printing zero for an attempt with no baseline.
|
|
func TestTheOperatorsTableCannotBeForgedByABooksOwnText(t *testing.T) {
|
|
dsn := freshDB(t)
|
|
t.Setenv("TM_PLATFORM_DSN", dsn)
|
|
seedALiveRun(t, dsn)
|
|
execSQL(t, dsn, `update books set title = 'A' || chr(9) || 'FORGED' || chr(10) || 'ROW'`)
|
|
execSQL(t, dsn, `update run_attempts
|
|
set reconcile_failures = 99, reconcile_error = $1,
|
|
spend_micro_usd = 123456, spend_baseline_micro_usd = 100000`,
|
|
"engine: "+strings.Repeat("蛊真人", 100))
|
|
|
|
got := capture(t, "runs", "--stalled")
|
|
rows := 0
|
|
for _, line := range strings.Split(strings.TrimSpace(got), "\n") {
|
|
if strings.Contains(line, "FORGED") {
|
|
rows++
|
|
}
|
|
}
|
|
switch {
|
|
case rows != 1:
|
|
t.Errorf("the book's title spans %d lines of the table: a newline in it forges rows\n%s", rows, got)
|
|
case strings.Contains(got, "A\tFORGED"):
|
|
t.Errorf("the book's title kept its tab: it forges a column in a table aligned by them\n%s", got)
|
|
case !utf8.ValidString(got):
|
|
t.Errorf("the table is not valid UTF-8: the engine's error was cut through a rune\n%q", got)
|
|
case !strings.Contains(got, "0.023456"):
|
|
t.Errorf("SPENT is not this attempt's own cost (123456 - 100000):\n%s", got)
|
|
}
|
|
// With no baseline the settlement refuses to price the attempt, and so does the table.
|
|
execSQL(t, dsn, `update run_attempts set spend_baseline_micro_usd = null`)
|
|
if got := capture(t, "runs", "--stalled"); !strings.Contains(got, "?") {
|
|
t.Errorf("an attempt the settlement will not price is given a figure anyway:\n%s", got)
|
|
}
|
|
// ⚠ AND THE CONTROL CHARACTERS `strings.Fields` DOES NOT TOUCH, asserted on the function because
|
|
// the table above cannot show them: tabwriter consumes a tab into padding, so the forged column
|
|
// is invisible in the output it produces. ESC is the one that matters — a title carrying terminal
|
|
// escapes rewrites the screen of the operator reading the table.
|
|
for _, r := range oneLine("A\x1b[2JB\x00C\x07D") {
|
|
if unicode.IsControl(r) {
|
|
t.Errorf("oneLine passes %q through to an operator's terminal", r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// seedAStuckSettlement is the OTHER population the operator's table must show: a run that ENDED and
|
|
// whose money never closed. Written with the same raw INSERTs as its live sibling above, and for the
|
|
// same reason — this package tests the COMMAND, and the state's own provenance is pinned where the
|
|
// state is produced (internal/runs, on state grown through the ordinary paths).
|
|
func seedAStuckSettlement(t *testing.T, dsn string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
execSQL(t, dsn, `insert into users (id, email) values ('u2','u2@example.org')`)
|
|
execSQL(t, dsn, `insert into books (id, owner_id, title, source_lang, target_lang, status,
|
|
chapter_count, workdir, engine_book_id, added_at, revision)
|
|
values ('bk2','u2','стоящая книга','zh','ru','failed',10,'/srv/books/bk2','bk2',$1,1)`, now)
|
|
execSQL(t, dsn, `insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at,
|
|
finished_at, revision)
|
|
values ('run_stuck','bk2','failed',false,10,$1,$1,1)`, now)
|
|
execSQL(t, dsn, `insert into run_attempts (run_id, attempt_no, started_at, ended_at, last_offset,
|
|
engine_run_id, spend_baseline_micro_usd,
|
|
reconcile_failures, reconcile_error)
|
|
values ('run_stuck',1,$1,$1,0,'run_stuck#1',0,7,'the settlement could not be computed')`, now)
|
|
execSQL(t, dsn, `insert into account_balances (user_id, balance_micro_usd, updated_at) values ('u2', 910000, $1)`, now)
|
|
execSQL(t, dsn, `insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id, note, created_at)
|
|
values ('u2','grant',1000000,'test','seed','',$1),
|
|
('u2','hold',-90000,'run','run_stuck#1','',$1)`, now)
|
|
execSQL(t, dsn, `insert into reservations (engine_run_id, user_id, book_id, amount_micro_usd,
|
|
ceiling_micro_usd, state, opened_at)
|
|
values ('run_stuck#1','u2','bk2',90000,90000,'open',$1)`, now)
|
|
}
|
|
|
|
// The operator's table shows BOTH halves of a stall and says which is which.
|
|
//
|
|
// The ERROR the reconciler logs at the threshold names this command by name. Until PD-385 the
|
|
// command answered "no run is failing to reconcile" over a run whose money was frozen, because the
|
|
// store's query was the live phase's alone — so the one sentence that tells an operator to act sent
|
|
// them to the one surface that could not show it.
|
|
//
|
|
// Mutations caught: dropping the PHASE column; the store narrowing back to live attempts; the empty
|
|
// message going back to "no run is live", which is a claim about half the question.
|
|
func TestTheRunsListingShowsAStuckSettlementAndNamesThePhase(t *testing.T) {
|
|
dsn := freshDB(t)
|
|
t.Setenv("TM_PLATFORM_DSN", dsn)
|
|
seedALiveRun(t, dsn)
|
|
seedAStuckSettlement(t, dsn)
|
|
|
|
plain := capture(t, "runs")
|
|
for _, want := range []string{"run_probe", "run_stuck", "live", "settling", "PHASE"} {
|
|
if !strings.Contains(plain, want) {
|
|
t.Errorf("the plain listing is missing %q — both halves belong to it:\n%s", want, plain)
|
|
}
|
|
}
|
|
stalled := capture(t, "runs", "--stalled")
|
|
if !strings.Contains(stalled, "run_stuck") {
|
|
t.Errorf("`runs --stalled` does not show the run whose settlement is stuck, which is exactly "+
|
|
"where the threshold's own ERROR sends an operator:\n%s", stalled)
|
|
}
|
|
if strings.Contains(stalled, "run_probe") {
|
|
t.Errorf("a live run with no failures is reported as stalled:\n%s", stalled)
|
|
}
|
|
// The hold is the reason the row is worth printing at all.
|
|
if !strings.Contains(stalled, "0.090000") {
|
|
t.Errorf("the stuck settlement's HELD column does not show the frozen hold:\n%s", stalled)
|
|
}
|
|
}
|
|
|
|
// A settlement that has NOT failed stays out of the plain table: every settlement is briefly open,
|
|
// and a table that lists healthy work is one an operator stops reading.
|
|
//
|
|
// Mutation caught: the store's floor for the settling half going from `greatest($1, 1)` to `$1`.
|
|
func TestASettlementInFlightIsNotInTheOperatorsTable(t *testing.T) {
|
|
dsn := freshDB(t)
|
|
t.Setenv("TM_PLATFORM_DSN", dsn)
|
|
seedAStuckSettlement(t, dsn)
|
|
execSQL(t, dsn, `update run_attempts set reconcile_failures = 0, reconcile_error = null`)
|
|
|
|
if got := capture(t, "runs"); strings.Contains(got, "run_stuck") {
|
|
t.Errorf("a settlement that has not failed is in the operator's table:\n%s", got)
|
|
}
|
|
// ⚠ AND THE OTHER SIDE OF THE SAME FLOOR, which is what makes it a floor and not a ceiling: ONE
|
|
// failure is where the row starts appearing. Without this the assertion above is satisfied by any
|
|
// threshold at all — two, five, never — and the number in `greatest($1, 1)` is unexecuted.
|
|
execSQL(t, dsn, `update run_attempts set reconcile_failures = 1`)
|
|
if got := capture(t, "runs"); !strings.Contains(got, "run_stuck") {
|
|
t.Errorf("a settlement that has failed ONCE is not in the plain table: one failure is the "+
|
|
"smallest number that means something went wrong, and it is where this row starts "+
|
|
"being worth printing\n%s", got)
|
|
}
|
|
}
|
|
|
|
// The verdict on a stuck settlement says what it actually did, and a second one says the state
|
|
// rather than "no such run".
|
|
//
|
|
// Both sentences matter: the first is the operator's only confirmation that the money moved, and the
|
|
// second is the difference between "you were late" and "you were wrong about the id" — answering the
|
|
// first with the second is how a frozen hold used to read as a typo.
|
|
//
|
|
// Mutation caught: printing the live branch's "its hold comes back whole on the next sweep" for a
|
|
// population no sweep will ever come for; mapping ErrMoneyAlreadyClosed back onto "is not a live run".
|
|
func TestAbandoningAStuckSettlementSaysWhatItDidAndThenSaysItIsDone(t *testing.T) {
|
|
dsn := freshDB(t)
|
|
t.Setenv("TM_PLATFORM_DSN", dsn)
|
|
seedAStuckSettlement(t, dsn)
|
|
|
|
got := capture(t, "run", "abandon", "--run", "run_stuck", "--reason", "the engine build was removed")
|
|
if !strings.Contains(got, "settlement") || !strings.Contains(got, "whole") {
|
|
t.Errorf("the verdict does not say that the settlement was given up on and the hold returned:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "on the next sweep") {
|
|
t.Error("the verdict promises a sweep, and for this population there will never be one")
|
|
}
|
|
// Second time: the state, not "no such run".
|
|
var out strings.Builder
|
|
err := run([]string{"run", "abandon", "--run", "run_stuck", "--reason", "again"}, &out)
|
|
if err == nil {
|
|
t.Fatal("abandoning twice was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "already closed") {
|
|
t.Errorf("the second verdict says %q, want the state it is actually in", err)
|
|
}
|
|
}
|