278 lines
12 KiB
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")
|
|
}
|
|
}
|