textmachine/platform/internal/runs/bankmovepending_test.go

278 lines
12 KiB
Go

package runs
import (
"context"
"errors"
"testing"
"time"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/runner"
)
// peekingApplier reads the world at the moment the verb would run, which is the only way to pin an
// ORDER rather than a final state: a mark written after the verb leaves exactly the same row behind.
type peekingApplier struct {
out runner.BankApplyOutcome
peek func()
}
func (p *peekingApplier) BankApply(_ context.Context, _, _, _ string, _ bool) (runner.BankApplyOutcome, error) {
p.peek()
return p.out, nil
}
// ⛔ THE WRITE-AHEAD MARK IS COMMITTED BEFORE THE VERB, AND THAT ORDER IS THE WHOLE MECHANISM.
//
// The fact «the bank moved» has one writer and no sweep, so a process that dies between the engine's
// files landing and the stamp leaves the book looking untouched (unified backlog row 400). A mark
// written AFTER the verb would leave an identical row on the healthy path and protect nothing on the
// one path it exists for — so the assertion is made from INSIDE the call, where «before» and «after»
// are distinguishable at all.
func TestTheWriteAheadMarkIsCommittedBeforeTheVerbRuns(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
var seen bool
var peekErr error
f.svc.Bank = &peekingApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
peek: func() {
// The door's own reader, not the raw column: what has to be true before the verb is that
// anything asking «did this book's bank move» already answers yes.
ctx, err := f.store.ReadBookForRun(f.ctx, "u1", book)
seen, peekErr = ctx.BankMoved, err
},
}
// The receipt has to name this book or the verdict refuses it.
f.svc.Bank.(*peekingApplier).out.Report.BookID = book
before, err := f.store.ReadBookForRun(f.ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if before.BankMoved {
t.Fatal("the fixture's book already reads as moved, so this test cannot see the mark arrive")
}
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: book, Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
if peekErr != nil {
t.Fatal(peekErr)
}
if !seen {
t.Error("the verb ran while the book still read as untouched: a death inside the verb would " +
"leave the correction unrecorded and the next resume would meet the engine's snapshot guard")
}
}
// ⛔ THE MONEY PROPERTY, and it lives on the RESUME rather than on a fresh start.
//
// `Start` hands `--resnapshot` to every continuation of a book that has run before (PD-422), so a
// start cannot isolate this fact at all — the flag would ride anyway and the pin would pass over a
// broken mechanism. `reopen` does not widen it: a resume reads the correction door's fact ALONE, and
// with it lost the resumed attempt is spawned without the flag and without its consent, meets a
// moved snapshot, and dies on the engine's guard AFTER its hold was taken (PD-425).
//
// The control below is the other half: with neither column set the same fixture grants neither
// consent, so what this measures is the mark and not the fixture.
func TestAResumeAfterACorrectionWhoseStampWasLostStillGrantsTheConsents(t *testing.T) {
for _, tc := range []struct {
name string
mark bool
wantCap int64
}{
{"the verb landed and the process died before the stamp", true, int64(fixtureHold(3))},
{"nothing was ever started on this book", false, 0},
} {
t.Run(tc.name, func(t *testing.T) {
f := newFixture(t, "10", 500)
runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
if tc.mark {
// Exactly what the door commits before it spawns the verb, and all that a death
// leaves behind: the stamp itself is never written.
if err := f.store.MarkBankMovePending(f.ctx, f.bookID(t), f.now); err != nil {
t.Fatal(err)
}
var stamped *time.Time
if err := f.store.Pool().QueryRow(f.ctx,
`select bank_moved_at from books where id = $1`, f.bookID(t)).Scan(&stamped); err != nil {
t.Fatal(err)
}
if stamped != nil {
t.Fatal("the fixture stamped the fact as well as the mark, so it no longer describes a lost stamp")
}
}
if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil {
t.Fatal(err)
}
var resnapshot bool
var consent int64
if err := f.store.Pool().QueryRow(f.ctx,
`select resnapshot, accept_rebill_micro from runs where id = $1`, runID).
Scan(&resnapshot, &consent); err != nil {
t.Fatal(err)
}
if resnapshot != tc.mark || consent != tc.wantCap {
t.Errorf("the resumed row carries resnapshot=%v consent=%d, want %v/%d",
resnapshot, consent, tc.mark, tc.wantCap)
}
})
}
}
// The mark stands in for the stamp and must not outlive it: a mark that survived its own stamp would
// be a fact nothing retires, and the flag would become permanent the first time a re-pass finished.
func TestAStampRetiresTheMarkItStoodInFor(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = book
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: book, Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
stamped, pending := bankColumns(t, f, book)
if stamped == nil {
t.Error("a correction that landed left no stamp")
}
if pending != nil {
t.Error("the stamp left the write-ahead mark standing: nothing else ever retires it")
}
}
// A door that landed NOTHING takes its mark back down, and the two shapes that prove it are the
// verb's own refusal and a clean report that neither changed anything nor re-found it. Everything
// else keeps the mark on purpose — a killed verb can die between its own two renames.
func TestACorrectionThatLandedNothingTakesItsMarkBackDown(t *testing.T) {
refused := okReport("refused")
refused.Changed = false
refused.Accepted = nil
refused.Rejected = []ingest.RejectedDecision{{Index: 0, Reason: "inert"}}
quiet := okReport("apply")
quiet.Changed = false
quiet.Accepted = nil
for _, tc := range []struct {
name string
out runner.BankApplyOutcome
}{
{"the verb read the document and declined it whole",
runner.BankApplyOutcome{ExitCode: ingest.ExitDecisionsRejected, Exited: true, Report: refused, Decoded: true}},
{"the verb accepted it and nothing moved",
runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: quiet, Decoded: true}},
} {
t.Run(tc.name, func(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
tc.out.Report.BookID = book
f.svc.Bank = &fakeBankApplier{out: tc.out}
// The error is the point of one case and not of the other, so neither is asserted here:
// what this test measures is the column.
_, _ = f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: book, Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
})
stamped, pending := bankColumns(t, f, book)
if stamped != nil {
t.Errorf("a correction that landed nothing stamped the fact anyway")
}
if pending != nil {
t.Errorf("the mark is still standing after a receipt that proves nothing landed: the " +
"next run would carry a --resnapshot for an act that never happened")
}
})
}
}
// ⛔ THE MINE THIS MECHANISM COMES WITH: whatever retires the FACT has to retire the MARK with it.
//
// `finish` retires the fact when a run that carried --resnapshot ends `ready` — the correction has
// demonstrably reached the text. A mark a dead process left behind is retired by nothing else, so a
// retirement that cleared only the stamp would meet the mark again on the very next admission: the
// flag would be permanent and no book would ever read quiet again.
func TestRetiringTheBankMoveRetiresItsWriteAheadMarkToo(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
if err := f.store.MarkBankMovePending(f.ctx, book, f.now); err != nil {
t.Fatal(err)
}
if err := f.store.RecordBankMove(f.ctx, book); err != nil {
t.Fatal(err)
}
// The premise: both facts stand, so clearing one of them is a state this test can tell apart.
if stamped, pending := bankColumns(t, f, book); stamped == nil || pending != nil {
t.Fatalf("the fixture cannot distinguish the two columns: stamp=%v mark=%v", stamped, pending)
}
// Put the mark back, as a death would have left it beside a stamp of an earlier correction.
if err := f.store.MarkBankMovePending(f.ctx, book, f.now); err != nil {
t.Fatal(err)
}
if err := f.store.ClearBankMove(f.ctx, book); err != nil {
t.Fatal(err)
}
stamped, pending := bankColumns(t, f, book)
if stamped != nil || pending != nil {
t.Errorf("retiring the fact left stamp=%v mark=%v: a leftover mark makes the flag permanent", stamped, pending)
}
ctx, err := f.store.ReadBookForRun(f.ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if ctx.BankMoved {
t.Error("the book still reads as moved after the fact was retired")
}
}
func bankColumns(t *testing.T, f *fixture, bookID string) (stamped, pending *time.Time) {
t.Helper()
if err := f.store.Pool().QueryRow(f.ctx,
`select bank_moved_at, bank_move_pending_at from books where id = $1`, bookID).
Scan(&stamped, &pending); err != nil {
t.Fatal(err)
}
return stamped, pending
}
// ⚠ A VERB WHOSE WRITE DID NOT COMPLETE KEEPS ITS MARK, and this is the half that decides what
// «nothing landed» may be read from.
//
// The engine's correction verb writes its files with two renames, and its class 15 exists to report
// the half-landed pair. The door has no way to know what reached the disk then, so the mark stays:
// the cost of keeping it is one harmless --resnapshot on the next run, and the cost of dropping it is
// a resume that meets a moved snapshot and dies after its hold was taken.
//
// ⛔ THE FIXTURE NAMES THE BOUNDARY IT CROSSES, and its first edition did not — which this pack's own
// mutation campaign proved rather than argued. That edition made the RUNNER fail (a verb that could
// not be executed at all), and on that path the door returns BEFORE the branch this test is about;
// the planting that clears the mark on every outcome therefore survived it, green. Exit 15 is the
// shape that reaches the branch: the verb ran, answered, and its answer is «the write did not
// complete».
func TestAVerbThatMayHaveDiedMidWriteKeepsItsMark(t *testing.T) {
f := newFixture(t, "10", 500)
book := f.bookID(t)
f.svc.Bank = &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: ingest.ExitWriteIncomplete, Exited: true},
}
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: book, Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); !errors.Is(err, ErrBankIncomplete) {
t.Fatalf("the door answered %v, not the incomplete-write remedy: this fixture no longer reaches "+
"the branch that decides whether the mark comes down", err)
}
stamped, pending := bankColumns(t, f, book)
if stamped != nil {
t.Error("a verb that never reported stamped the fact")
}
if pending == nil {
t.Error("the mark was taken down without a receipt: a kill between the verb's two renames leaves " +
"the correction half-landed, and the next resume would meet the engine's snapshot guard")
}
}