package store import ( "fmt" "math" "math/rand" "os" "os/exec" "path/filepath" "syscall" "testing" "time" ) // kill9_test.go — приёмочный тест Р6/Фазы 0: kill -9 посреди работы теряет // максимум один in-flight вызов и НИКОГДА не рассинхронизирует деньги с // чекпоинтами. Настоящий SIGKILL по подпроцессу (helper-process паттерн // стандартной библиотеки), не эмуляция. // // Инвариант после любого убийства: committed_usd == SUM(checkpoints.cost_usd) // — потому что settle и чекпоинт пишутся ОДНОЙ транзакцией. Осиротевшие // reserved_usd снимаются recovery-проходом при открытии. const killLoopEnv = "TM_STORE_KILL_LOOP_DB" func TestHelperKillLoop(t *testing.T) { path := os.Getenv(killLoopEnv) if path == "" { t.Skip("helper process only") } s, err := Open(path) if err != nil { fmt.Fprintln(os.Stderr, "helper open:", err) os.Exit(1) } if err := s.UpsertSnapshot("snap", "brief", `{}`); err != nil { os.Exit(1) } job, err := s.EnsureJob("book", 1, "draft", "snap") if err != nil { os.Exit(1) } // Бесконечный цикл reserve → settle+checkpoint; родитель убьёт нас SIGKILL // в случайный момент. Никаких Release — «упавшие» резервы снимет recovery. for i := 0; ; i++ { res, verdict, err := s.Reserve("book", 0.002, Ceilings{BookUSD: 10_000}) if err != nil || verdict != ReserveOK { os.Exit(1) } cp := Checkpoint{ RequestHash: fmt.Sprintf("hash-%d-%d", os.Getpid(), i), JobID: job.ID, ChunkIdx: i, Stage: "draft", Role: "translator", ModelRequested: "m", ModelActual: "m", ResponseText: "ответ", UsageJSON: "{}", CostUSD: 0.001, } if err := s.SettleWithCheckpoint(res, 0.001, cp); err != nil { os.Exit(1) } } } func TestKillMinus9LosesAtMostOneCall(t *testing.T) { if testing.Short() { t.Skip("subprocess test") } exe, err := os.Executable() if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "kill9.db") for round := 0; round < 3; round++ { cmd := exec.Command(exe, "-test.run", "^TestHelperKillLoop$", "-test.v") cmd.Env = append(os.Environ(), killLoopEnv+"="+path) if err := cmd.Start(); err != nil { t.Fatal(err) } // Даём поработать случайное время, затем SIGKILL — никакого graceful. time.Sleep(time.Duration(150+rand.Intn(400)) * time.Millisecond) if err := cmd.Process.Signal(syscall.SIGKILL); err != nil { t.Fatal(err) } _ = cmd.Wait() s, err := Open(path) // recovery-проход снимает зависшие резервы if err != nil { t.Fatalf("round %d: reopen after kill: %v", round, err) } var checkpointSum float64 var checkpointCount int if err := s.r.QueryRow(`SELECT COALESCE(SUM(cost_usd),0), COUNT(*) FROM checkpoints`).Scan(&checkpointSum, &checkpointCount); err != nil { t.Fatal(err) } committed, reserved, err := s.SpentUSD("book") if err != nil { t.Fatal(err) } s.Close() if reserved != 0 { t.Fatalf("round %d: recovery must clear reservations, reserved=%v", round, reserved) } // Ядро приёмки: деньги и чекпоинты атомарны — расхождение значило бы // оплаченный, но потерянный (или бесплатный, но записанный) вызов. if math.Abs(committed-checkpointSum) > 1e-9 { t.Fatalf("round %d: committed=%v != sum(checkpoints)=%v over %d checkpoints — settle+checkpoint not atomic", round, committed, checkpointSum, checkpointCount) } if checkpointCount == 0 && round > 0 { t.Fatalf("round %d: helper made no progress", round) } t.Logf("round %d: %d checkpoints, committed=$%.6f — consistent after SIGKILL", round, checkpointCount, committed) } }