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