Fix tmctl report: materialize request_log rows so they outlive the query context that defer-cancel tore down mid-iteration

This commit is contained in:
Claude (backend session) 2026-07-04 16:56:21 +03:00
parent c4676d53dc
commit 56c2f5a61c
4 changed files with 101 additions and 16 deletions

View file

@ -109,22 +109,14 @@ func report(cfgPath string) error {
if err != nil {
return err
}
defer rows.Close()
fmt.Printf("%-20s %-8s %-12s %-22s %8s %8s %8s %8s %8s %10s %8s %-8s %-5s %-3s\n",
"ts", "stage", "role", "model", "prompt", "cached", "cwrite", "compl", "reason", "cost_usd", "ms", "finish", "tmhit", "ok")
for rows.Next() {
var ts, stage, role, model, finish string
var prompt, cached, cwrite, compl, reason, latency, tmhit, ok int
var cost float64
if err := rows.Scan(&ts, &stage, &role, &model, &prompt, &cached, &cwrite, &compl, &reason, &cost, &latency, &finish, &tmhit, &ok); err != nil {
return err
}
for _, row := range rows {
fmt.Printf("%-20s %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d\n",
ts, stage, role, model, prompt, cached, cwrite, compl, reason, cost, latency, finish, tmhit, ok)
}
if err := rows.Err(); err != nil {
return err
row.TS, row.Stage, row.Role, row.ModelActual, row.PromptTokens, row.CachedTokens,
row.CacheCreationTokens, row.CompletionTokens, row.ReasoningTokens, row.CostUSD,
row.LatencyMS, row.FinishReason, row.TMHit, row.OK)
}
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)

View file

@ -2,7 +2,6 @@ package store
import (
"context"
"database/sql"
"log/slog"
"textmachine/backend/internal/obs"
@ -70,13 +69,51 @@ func (s *Store) LogRequest(ctx context.Context, log *slog.Logger, rl RequestLog)
}
}
// RequestLogRows returns rows for inspection (tmctl report / приёмка Фазы 0).
func (s *Store) RequestLogRows(bookID string) (*sql.Rows, error) {
// RequestLogView is one request_log row for inspection (tmctl report).
type RequestLogView struct {
TS string
Stage string
Role string
ModelActual string
PromptTokens int
CachedTokens int
CacheCreationTokens int
CompletionTokens int
ReasoningTokens int
CostUSD float64
LatencyMS int
FinishReason string
TMHit int
OK int
}
// RequestLogRows returns all request_log rows for a book (tmctl report / приёмка
// Фазы 0). Строки МАТЕРИАЛИЗУЮТСЯ под op-таймаутом и возвращаются срезом —
// отдавать *sql.Rows нельзя: ленивая итерация у вызывающего переживает
// `defer cancel()` этого метода, и контекст отменяется ПОСРЕДИ чтения
// («context canceled», обрыв таблицы report — находка реальной приёмки).
func (s *Store) RequestLogRows(bookID string) ([]RequestLogView, error) {
ctx, cancel := opContext()
defer cancel()
return s.r.QueryContext(ctx, `
rows, err := s.r.QueryContext(ctx, `
SELECT ts, stage, role, model_actual, prompt_tokens, cached_tokens,
cache_creation_tokens, completion_tokens, reasoning_tokens,
cost_usd, latency_ms, finish_reason, tm_hit, ok
FROM request_log WHERE book_id = ? ORDER BY id`, bookID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RequestLogView
for rows.Next() {
var v RequestLogView
if err := rows.Scan(&v.TS, &v.Stage, &v.Role, &v.ModelActual,
&v.PromptTokens, &v.CachedTokens, &v.CacheCreationTokens,
&v.CompletionTokens, &v.ReasoningTokens, &v.CostUSD,
&v.LatencyMS, &v.FinishReason, &v.TMHit, &v.OK); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}

View file

@ -178,6 +178,33 @@ func TestJobKeepsOriginalSnapshot(t *testing.T) {
}
}
// RequestLogRows обязан вернуть ВСЕ строки, читаемые вызывающим ПОСЛЕ возврата
// метода: раньше он отдавал *sql.Rows с `defer cancel()`, и контекст отменялся
// посреди ленивой итерации в tmctl report («context canceled», обрыв таблицы —
// находка реальной приёмки Фазы 0).
func TestRequestLogRowsMaterializes(t *testing.T) {
s, _ := openTemp(t)
for i := 1; i <= 3; i++ {
if err := s.InsertRequestLog(RequestLog{BookID: "book", Stage: "draft", PromptTokens: 10 * i, OK: true}); err != nil {
t.Fatal(err)
}
}
// Строка другой книги не должна попасть в выборку.
if err := s.InsertRequestLog(RequestLog{BookID: "other", Stage: "draft", OK: true}); err != nil {
t.Fatal(err)
}
rows, err := s.RequestLogRows("book")
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("want 3 materialized rows, got %d (context canceled mid-iteration?)", len(rows))
}
if rows[0].PromptTokens != 10 || rows[2].PromptTokens != 30 {
t.Fatalf("rows not in id order or wrong data: %+v", rows)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
for i := 0; i < 3; i++ {

View file

@ -152,6 +152,34 @@ per-chunk skip+flag вместо падения; структурный запр
Следующее: Фаза 1 (перевод книги целиком) — чанкер, банк памяти v1, гейты (пороги полигона готовы), режим онгоинга.
### Реальная приёмка на живых ключах — PASS (04.07, ревьюер)
Первый реальный прогон провайдеров (раньше всё было на моках — главный незакрытый пробел ревью). `tmctl translate`
на `example/book.yaml` (draft=deepseek-v4-flash → edit=glm-5, один чанк 264 симв., потолок $1.00):
- **Деньги сходятся до цента**: draft $0.000219 (453·0.14+556·0.28/1e6 ✓), edit $0.001277 (672·1.0+189·3.2/1e6 ✓);
`committed == Σстадий == $0.001496`, `reserved=0`. Порядок величин совпал с прайсингом DeepSeek/z.ai.
- **Цена по ответившей**: z.ai вернул `model=glm-5` → edit книжится по цене GLM, НЕ по дешёвому deepseek-якорю
(fallback дал бы $0.000147 — в 8.7× меньше); `PriceForResponse` подтверждён на реале.
- **Usage реальный**, не мок: `prompt/completion_tokens` ненулевые в request_log; GLM латентность штатная (58 с,
без ×3) → `extra_body thinking:{type:disabled}` фактически применён.
- **Resume**: второй прогон — обе стадии из чекпоинтов за **$0**, ledger неизменен (нет двойной оплаты).
- **Пост-ревью правки провалидированы**: F1 (CheckKeys — заводится на 2 ключах), F2 (provider temp/maxtok в
snapshot), F5 (fail-loud на включённый гейт), F7 (сноска §3.4) — все на месте; Anthropic вычищен из конфигов,
висячих ссылок нет (в `BuildClient` ветка `case "anthropic"` осталась, но `kind: anthropic` в models.yaml нет).
**Новая находка реальной приёмки (исправлена):** `tmctl report` рвался с `context canceled` и печатал таблицу
частично/пусто без строки Ledger (exit 1). Причина — `Store.RequestLogRows` отдавал `*sql.Rows` с `defer cancel()`:
контекст отменялся посреди ленивой итерации у вызывающего. Данные и деньги в БД были корректны — баг только на
read-пути `report`. Фикс: строки материализуются под таймаутом и возвращаются срезом `[]RequestLogView`;
регрессионный тест `TestRequestLogRowsMaterializes`. `go test ./... -race` зелёные.
**Не покрыто реальным прогоном (честно):** (а) DeepSeek cache-hit парсинг — resume короткозамыкает вызов, так что
реального повторного prefix-хита не было; закрыт только unit-тестом `TestDeepSeekCacheFieldsVariant` (форсить
реальный хит = лишние траты, не гнал); (б) kill-9 посреди реального провайдерского вызова — инвариант
`committed==SUM(checkpoints)` доказан unit-тестом `kill9_test.go` (настоящий SIGKILL на SQLite), на реале не
воспроизводил (деньги/один-чанк дисциплина). (в) Anthropic cache_control/usage-сумма — Anthropic из стека убран,
проверять больше нечего на этом контуре.
## Полигон
(секция параллельной сессии — записи добавлять сюда)
@ -175,6 +203,7 @@ per-chunk skip+flag вместо падения; структурный запр
- **llama.cpp `--n-cpu-moe` для 30b-a3b — перемерено, «Расхождение» с research/06 подтверждено**: потолок генерации на GTX 1070 **~13.5 tok/s** (не 2530). Таблица конфигов в [03](experiments/03-local-stand.md): ollama 10 → llama.cpp `--cpu-moe` 11.2 → `--n-cpu-moe 33` 13.5 (VRAM 2.9→7.8GB, prefill 105→208). Узкое место — пропускная способность CPU-RAM (DDR4/Pascal), не рантайм; llama.cpp честно лучше ollama (+35% ген., ×23 prefill), но 30b-a3b на этом железе — только фоновые роли (~2.5 мин/главу). Скрипт: `eval/llama_moe_bench.sh`.
- **Замер 4 новых dense-моделей** (ваниль qwen3:8b / qwen3.5:9b / +abliterated / ruadapt) — готово, таблица в [03](experiments/03-local-stand.md). Три вывода: (1) **абляция бесплатна** — qwen3.5 vanilla vs abliterated идентичны по скорости и SFW-качеству → 18+-роль можно на abliterated без штрафа; (2) поколение 3.5>3 умеренно (лучше понимание, 25% токенов, 10% скорости); (3) **RuAdapt: токен-выигрыш подтверждён (40% ru-токенов), но перевод развалился** (羅生門→«дерево с колокольчиками») → редактор ru-стиля, не переводчик (как и предупреждал research/06). Разрыв с API держится у всех. **Побочная находка для бэкенда:** thinking у Qwen3.5+/GLM/Gemini для роли переводчика обязателен к отключению (иначе пустой/обрезанный вывод — ловилось дважды: эксп. 02 и здесь).
- **Think-режим (проверка гипотезы владельца «reasoning поднимет качество в разы»)** — [03](experiments/03-local-stand.md), раздел «Влияние thinking». Итог: (а) прежняя «пустота» — артефакт тесного контекста (рассуждения не влезали с ответом), не зависание; (б) с ctx 16k think **завершается и реально чинит реалии** (羅生門: «Радзёмон»→«Рашо-мон»; 朱雀 с китайского чтения на японское) — гипотеза частично подтвердилась; (в) но локально нежизнеспособен: 8,6 мин и ~11k токенов рассуждений на 1400 знаков (вебновелла ≈ 200 ч). **Новый пункт бэклога → оркестратору/бэкенду: проверить think-ON vs OFF по КАЧЕСТВУ на быстром облачном черновике/редакторе (DeepSeek/GLM)** — потенциальный дешёвый рычаг качества, раз reasoning вытаскивает реалии.
### Следующие шаги полигона
- [x] Прогон refusal-бенчмарка (SFW/L1/L2-violence часть) — 66/66 ok, пороги в 02; explicit-часть ждёт фрагментов владельца.