textmachine/backend/internal/store/outbox_test.go

316 lines
12 KiB
Go

package store
import (
"errors"
"fmt"
"strings"
"testing"
)
// outbox_test.go pins the two properties that put the run-event outbox in SQLite instead of in a
// variable: a sequence number that a rollback gives back (the reader treats a hole as fatal), and bytes
// that survive to be re-projected identically (the reader treats a changed payload at a known seq as
// corruption).
func line(seq int64) []byte { return []byte(fmt.Sprintf(`{"seq":%d}`, seq)) }
func enqueue(t *testing.T, s *Store, runID string, want int64) {
t.Helper()
if err := s.EnqueueEvent(runID, func(seq int64) ([]byte, error) {
if seq != want {
t.Errorf("next seq for %s = %d, want %d", runID, seq, want)
}
return line(seq), nil
}); err != nil {
t.Fatal(err)
}
}
func TestSequenceNumbersAreDenseAndPerRun(t *testing.T) {
s, _ := openTemp(t)
enqueue(t, s, "run-a", 1)
enqueue(t, s, "run-a", 2)
// A second process numbers from 1 again in the SAME book: the ratified idempotency key is
// (engine_run_id, seq), so a resumed run is a new stream, not a continuation of the old numbers.
enqueue(t, s, "run-b", 1)
enqueue(t, s, "run-a", 3)
got, err := s.PendingEvents("run-a", 0, 100)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("want run-a's three lines, got %d", len(got))
}
for i, e := range got {
if e.Seq != int64(i+1) {
t.Fatalf("run-a seq %d at position %d — the stream must be dense and ordered", e.Seq, i)
}
}
}
func TestARolledBackEventConsumesNoSequenceNumber(t *testing.T) {
// The hole is what makes this worth a transaction: the reader's ErrStreamGap is fatal, so a number
// handed out to a render that then failed must go back.
s, _ := openTemp(t)
enqueue(t, s, "run", 1)
boom := errors.New("render failed")
if err := s.EnqueueEvent("run", func(int64) ([]byte, error) { return nil, boom }); !errors.Is(err, boom) {
t.Fatalf("want the render error back, got %v", err)
}
enqueue(t, s, "run", 2) // NOT 3
}
func TestAStoredLineIsHandedBackByteForByte(t *testing.T) {
// Re-projection after a failed journal write must produce the identical line: the reader compares a
// re-read line against the sha256 it stored, and a fresher render at the same seq is a payload
// conflict, which quarantines the projection.
s, _ := openTemp(t)
original := []byte(`{"seq":1,"type":"spend","time":"2026-08-14T10:00:00Z","data":{"committed_micro_usd":7}}`)
if err := s.EnqueueEvent("run", func(int64) ([]byte, error) { return original, nil }); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
got, err := s.PendingEvents("run", 0, 100)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || string(got[0].Line) != string(original) {
t.Fatalf("read %d: %+v, want %q", i, got, original)
}
}
}
func TestTheAnnounceLedgerRecordsDeliveryAndNotIntent(t *testing.T) {
// The order is the design: an event is ledgered only once its line is ON THE FILE. A row that was
// enqueued and never delivered leaves no ledger entry, so the next process announces it again —
// at-least-once, which is what the ratified contract asks for at a boundary no transaction spans.
s, _ := openTemp(t)
announcing, seq, err := s.EnqueueOnce("run", "unit:draft:1:0", func(seq int64) ([]byte, error) { return line(seq), nil })
if err != nil || !announcing || seq != 1 {
t.Fatalf("first announcement: announcing=%v seq=%d err=%v", announcing, seq, err)
}
// Not yet delivered ⇒ not yet ledgered.
if err := s.MarkAnnounced("run", map[int64]string{seq: "unit:draft:1:0"}); err != nil {
t.Fatal(err)
}
announcing, _, err = s.EnqueueOnce("run", "unit:draft:1:0", func(seq int64) ([]byte, error) { return line(seq), nil })
if err != nil {
t.Fatal(err)
}
if announcing {
t.Fatal("a delivered announcement must not be made twice — a reader increments on it")
}
// A DIFFERENT unit is unaffected, and numbering continues where it left off.
announcing, seq, err = s.EnqueueOnce("run", "unit:edit:1:0", func(seq int64) ([]byte, error) { return line(seq), nil })
if err != nil || !announcing || seq != 2 {
t.Fatalf("second unit: announcing=%v seq=%d err=%v", announcing, seq, err)
}
}
func TestAnUndeliveredAnnouncementIsRetriedByTheNextRunAndADeliveredOneIsNot(t *testing.T) {
// The crash case, at the storage layer: one unit's line reached the file, another's did not. After the
// next process clears the buffer, the delivered one is still ledgered and the undelivered one is free
// to be announced again.
s, _ := openTemp(t)
_, delivered, err := s.EnqueueOnce("dead-run", "unit:draft:1:0", func(seq int64) ([]byte, error) { return line(seq), nil })
if err != nil {
t.Fatal(err)
}
if _, _, err := s.EnqueueOnce("dead-run", "unit:draft:2:0", func(seq int64) ([]byte, error) { return line(seq), nil }); err != nil {
t.Fatal(err)
}
if err := s.MarkAnnounced("dead-run", map[int64]string{delivered: "unit:draft:1:0"}); err != nil {
t.Fatal(err)
}
if err := s.ForgetEvents("live-run"); err != nil {
t.Fatal(err)
}
if announcing, _, err := s.EnqueueOnce("live-run", "unit:draft:1:0", func(seq int64) ([]byte, error) { return line(seq), nil }); err != nil || announcing {
t.Fatalf("the delivered unit must stay silent: announcing=%v err=%v", announcing, err)
}
if announcing, _, err := s.EnqueueOnce("live-run", "unit:draft:2:0", func(seq int64) ([]byte, error) { return line(seq), nil }); err != nil || !announcing {
t.Fatalf("the unit whose line never landed must be announced again: announcing=%v err=%v", announcing, err)
}
}
func TestPendingEventsStartAfterTheCursor(t *testing.T) {
s, _ := openTemp(t)
for i := int64(1); i <= 4; i++ {
enqueue(t, s, "run", i)
}
got, err := s.PendingEvents("run", 2, 100)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Seq != 3 || got[1].Seq != 4 {
t.Fatalf("want seq 3,4 after the cursor at 2, got %+v", got)
}
}
func TestForgetEventsKeepsOnlyTheCurrentRun(t *testing.T) {
// The outbox is a projection buffer, not an archive — otherwise a book resumed a hundred times keeps
// a hundred runs' worth of lines nothing will ever read.
s, _ := openTemp(t)
enqueue(t, s, "old", 1)
enqueue(t, s, "new", 1)
if err := s.ForgetEvents("new"); err != nil {
t.Fatal(err)
}
if got, err := s.PendingEvents("old", 0, 100); err != nil || len(got) != 0 {
t.Fatalf("the previous run's lines must be gone, got %+v (%v)", got, err)
}
if got, err := s.PendingEvents("new", 0, 100); err != nil || len(got) != 1 {
t.Fatalf("this run's lines must survive, got %+v (%v)", got, err)
}
}
func TestTheSpendEventIsCommittedByTheSettleThatEarnedIt(t *testing.T) {
s, _ := openTemp(t)
job := mustSnapshotAndJob(t, s)
caps := Ceilings{BookUSD: 1.0, DayUSD: 2.0}
res, verdict, err := s.Reserve("book", 0.10, caps)
if err != nil || verdict != ReserveOK {
t.Fatalf("reserve: %v %v", verdict, err)
}
cp := Checkpoint{RequestHash: "h1", JobID: job.ID, Stage: "draft", Role: "translator",
ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04}
var sawCommitted float64
spend := &SpendLine{RunID: "run", Line: func(seq int64, committedUSD float64) ([]byte, error) {
sawCommitted = committedUSD
return line(seq), nil
}}
if err := s.SettleWithCheckpoint(res, 0.04, cp, spend); err != nil {
t.Fatal(err)
}
// The figure is read INSIDE the transaction, AFTER the debit: an event that reported the pre-settle
// total would under-report the book's spend by exactly the call it is announcing.
if sawCommitted != 0.04 {
t.Fatalf("the event saw committed=%v, want the post-settle 0.04", sawCommitted)
}
if got, err := s.PendingEvents("run", 0, 100); err != nil || len(got) != 1 {
t.Fatalf("the settle must have booked its event, got %+v (%v)", got, err)
}
}
func TestASettleThatRollsBackLeavesNeitherCheckpointNorEvent(t *testing.T) {
// This is the whole claim of "the event line is written in the SAME transaction as the checkpoint":
// the two cannot disagree, in either direction. (The driver never lets a render failure reach here —
// losing a paid checkpoint to protect an indicator would be the wrong way round — but the storage
// layer's guarantee is what that policy rests on.)
s, _ := openTemp(t)
job := mustSnapshotAndJob(t, s)
res, _, err := s.Reserve("book", 0.10, Ceilings{BookUSD: 1.0})
if err != nil {
t.Fatal(err)
}
cp := Checkpoint{RequestHash: "h-doomed", JobID: job.ID, Stage: "draft", Role: "translator",
ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04}
boom := errors.New("render failed")
spend := &SpendLine{RunID: "run", Line: func(int64, float64) ([]byte, error) { return nil, boom }}
if err := s.SettleWithCheckpoint(res, 0.04, cp, spend); !errors.Is(err, boom) {
t.Fatalf("want the render error, got %v", err)
}
got, err := s.GetCheckpoint("h-doomed")
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatal("the checkpoint must have rolled back with the event")
}
committed, _, err := s.SpentUSD("book")
if err != nil {
t.Fatal(err)
}
if committed != 0 {
t.Fatalf("the debit must have rolled back too, committed=%v", committed)
}
if lines, err := s.PendingEvents("run", 0, 100); err != nil || len(lines) != 0 {
t.Fatalf("no event may survive the transaction that failed, got %+v (%v)", lines, err)
}
}
func TestADuplicateSettleBooksNoSecondSpendEvent(t *testing.T) {
// A duplicate settle books no money, so it must announce none: the counter is cumulative and a second
// line at the same total is noise the reader would still have to hash and store.
s, _ := openTemp(t)
job := mustSnapshotAndJob(t, s)
cp := Checkpoint{RequestHash: "h1", JobID: job.ID, Stage: "draft", Role: "translator",
ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04}
spend := &SpendLine{RunID: "run", Line: func(seq int64, _ float64) ([]byte, error) { return line(seq), nil }}
for i := 0; i < 2; i++ {
res, _, err := s.Reserve("book", 0.10, Ceilings{BookUSD: 1.0})
if err != nil {
t.Fatal(err)
}
if err := s.SettleWithCheckpoint(res, 0.04, cp, spend); err != nil {
t.Fatal(err)
}
}
lines, err := s.PendingEvents("run", 0, 100)
if err != nil {
t.Fatal(err)
}
if len(lines) != 1 {
t.Fatalf("want one spend event for one billed call, got %d", len(lines))
}
}
func TestTheAnnounceLedgerLookupUsesItsIndex(t *testing.T) {
// The uniqueness index on once_key is PARTIAL, and SQLite uses a partial index only when the query
// implies its predicate SYNTACTICALLY — with a bind parameter it cannot prove ?1 <> '' while planning.
// The bare equality therefore plans as a full SCAN, once per announced unit: quadratic over a book, on
// the wave's own path. Reproduced on modernc.org/sqlite before the fix; this is the pin.
s, _ := openTemp(t)
rows, err := s.r.Query("EXPLAIN QUERY PLAN "+onceKeyLookup, "unit:x:1:0")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
plan := ""
for rows.Next() {
var a, b, c int
var detail string
if err := rows.Scan(&a, &b, &c, &detail); err != nil {
t.Fatal(err)
}
plan += detail + "\n"
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if !strings.Contains(plan, "USING COVERING INDEX events_outbox_once") {
t.Fatalf("the announce-ledger lookup does not use its index:\n%s", plan)
}
if strings.Contains(plan, "SCAN events_outbox") {
t.Fatalf("the announce-ledger lookup scans the table:\n%s", plan)
}
}
func TestPendingEventsReturnsAtMostOneBatch(t *testing.T) {
// The bound is what keeps a DEGRADED journal from being quadratic: the caller's cursor cannot advance
// while the file refuses writes, so an unbounded read would re-materialize the whole growing prefix on
// every event, under the emitter's mutex.
s, _ := openTemp(t)
for i := int64(1); i <= 10; i++ {
enqueue(t, s, "run", i)
}
got, err := s.PendingEvents("run", 0, 4)
if err != nil {
t.Fatal(err)
}
if len(got) != 4 || got[0].Seq != 1 || got[3].Seq != 4 {
t.Fatalf("want the first four, got %d rows starting at %d", len(got), got[0].Seq)
}
rest, err := s.PendingEvents("run", got[3].Seq, 100)
if err != nil {
t.Fatal(err)
}
if len(rest) != 6 || rest[0].Seq != 5 {
t.Fatalf("the next read must continue at 5 and drain, got %d rows", len(rest))
}
}