166 lines
8.7 KiB
Go
166 lines
8.7 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
)
|
||
|
||
// migrations are applied in order; schema_version records the highest applied
|
||
// version so re-runs are no-ops. Every step is also idempotent (CREATE TABLE
|
||
// IF NOT EXISTS) so a half-applied database still converges. Append-only:
|
||
// never edit an earlier migration (дисциплина vojo).
|
||
var migrations = []string{
|
||
// v1: the Phase-0 operational schema.
|
||
`
|
||
-- Snapshot контекста джобы (Р6, implementation-notes §3.2): материализованная
|
||
-- запись; snapshot_id входит в request-hash, так что волатильность контекста
|
||
-- заморожена на джобу. payload — JSON (версии промптов per-role, модели,
|
||
-- версия чанкера, style sheet, резюме на момент старта).
|
||
CREATE TABLE IF NOT EXISTS snapshots (
|
||
snapshot_id TEXT PRIMARY KEY,
|
||
brief_hash TEXT NOT NULL,
|
||
payload TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
-- Durable jobs: джоба = глава×стадия (Р6). Гранулярность СОХРАННОСТИ — чанк
|
||
-- (checkpoints), джоба лишь группирует их и несёт snapshot_id.
|
||
CREATE TABLE IF NOT EXISTS jobs (
|
||
id INTEGER PRIMARY KEY,
|
||
book_id TEXT NOT NULL,
|
||
chapter INTEGER NOT NULL,
|
||
stage TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed
|
||
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id),
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
UNIQUE (book_id, chapter, stage)
|
||
);
|
||
|
||
-- Чанк-чекпоинты: сырой ответ LLM персистится атомарно вместе с settle
|
||
-- (одна транзакция — SettleWithCheckpoint), ключ — request_hash от
|
||
-- детерминированного рендера (§3.1). kill -9 теряет максимум один
|
||
-- in-flight вызов. Найденный чекпоинт на resume = вызов не повторяется и
|
||
-- не оплачивается второй раз.
|
||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||
request_hash TEXT PRIMARY KEY,
|
||
job_id INTEGER NOT NULL REFERENCES jobs(id),
|
||
chunk_idx INTEGER NOT NULL,
|
||
attempt INTEGER NOT NULL DEFAULT 0, -- регенерации Фазы 1: attempt входит в request-hash
|
||
stage TEXT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
model_requested TEXT NOT NULL,
|
||
model_actual TEXT NOT NULL,
|
||
response_text TEXT NOT NULL,
|
||
usage_json TEXT NOT NULL,
|
||
cost_usd REAL NOT NULL,
|
||
finish_reason TEXT NOT NULL DEFAULT '',
|
||
provider_request_id TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS checkpoints_job_idx ON checkpoints (job_id, chunk_idx);
|
||
|
||
-- Дневной/книжный ledger с резервированием (порт семантики vojo spend):
|
||
-- потолки считают committed + reserved на допуске, поэтому burst
|
||
-- конкурентных вызовов не проскакивает потолок (TOCTOU-дисциплина).
|
||
CREATE TABLE IF NOT EXISTS spend (
|
||
book_id TEXT NOT NULL,
|
||
date TEXT NOT NULL, -- UTC YYYY-MM-DD
|
||
committed_usd REAL NOT NULL DEFAULT 0,
|
||
reserved_usd REAL NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (book_id, date)
|
||
);
|
||
|
||
-- request_log: одна строка на LLM-вызов (Р7: per-книга/глава/чанк/стадия/
|
||
-- роль/модель, $, latency, cache-поля). Телеметрия, не источник денег —
|
||
-- деньги живут в spend/checkpoints.
|
||
CREATE TABLE IF NOT EXISTS request_log (
|
||
id INTEGER PRIMARY KEY,
|
||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||
trace_id TEXT NOT NULL DEFAULT '',
|
||
book_id TEXT NOT NULL,
|
||
chapter INTEGER NOT NULL DEFAULT 0,
|
||
chunk_idx INTEGER NOT NULL DEFAULT 0,
|
||
stage TEXT NOT NULL DEFAULT '',
|
||
role TEXT NOT NULL DEFAULT '',
|
||
model_requested TEXT NOT NULL DEFAULT '',
|
||
model_actual TEXT NOT NULL DEFAULT '',
|
||
request_hash TEXT NOT NULL DEFAULT '',
|
||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cost_usd REAL NOT NULL DEFAULT 0,
|
||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||
finish_reason TEXT NOT NULL DEFAULT '',
|
||
tm_hit INTEGER NOT NULL DEFAULT 0, -- ответ взят из чекпоинта, вызова не было
|
||
degraded TEXT NOT NULL DEFAULT '',
|
||
err TEXT NOT NULL DEFAULT '',
|
||
ok INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts);
|
||
`,
|
||
// v2: per-chunk×stage disposition (Веха 2, D2). Резолв ПОВЕРХ append-only
|
||
// checkpoints (keyed by request_hash-с-attempt) — не колонка на checkpoints:
|
||
// на resume строка пересобирается из чекпоинтов, поэтому её потеря на kill -9
|
||
// самолечится (classify по тексту чекпоинта, без ре-биллинга). jobs.status
|
||
// контентом НЕ расширяется — failed остаётся инфра-сбоем; плохой чанк живёт
|
||
// здесь как disposition, а цикл продолжается.
|
||
`
|
||
CREATE TABLE IF NOT EXISTS chunk_status (
|
||
book_id TEXT NOT NULL,
|
||
chapter INTEGER NOT NULL,
|
||
chunk_idx INTEGER NOT NULL,
|
||
stage TEXT NOT NULL,
|
||
snapshot_id TEXT NOT NULL, -- под каким снапшотом резолвнут (stale после --resnapshot игнорируется)
|
||
content_hash TEXT NOT NULL DEFAULT '',-- сигнатура отрендеренных msgs (исходник НЕ в снапшоте): правка src делает позиционную строку недействительной, а не подаёт устаревший перевод
|
||
disposition TEXT NOT NULL, -- ok | flagged | skipped
|
||
flag_reason TEXT NOT NULL DEFAULT '', -- '' когда ok; Go-константа FlagReason
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
final_hash TEXT NOT NULL DEFAULT '', -- request_hash авторитетного чекпоинта (ok-путь подаёт его текст дальше)
|
||
cost_usd REAL NOT NULL DEFAULT 0, -- сумма по ВСЕМ попыткам (F3-честно: ретраи учтены)
|
||
detail TEXT NOT NULL DEFAULT '',
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
PRIMARY KEY (book_id, chapter, chunk_idx, stage)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS chunk_status_book_idx ON chunk_status (book_id, disposition);
|
||
`,
|
||
// v3: escalation flag on checkpoints (Веха 2.5, D12 single-hop). The fallback
|
||
// draft (a deterministic content-failure routed to another model ONCE) is tagged
|
||
// so the book's escalation.budget_usd can be enforced by summing ONLY escalation
|
||
// spend, independent of the primary translation cost. On the checkpoint (written
|
||
// in the same tx as the settle), NOT request_log, so the sum is durable across
|
||
// resume / kill -9 (telemetry is fire-and-forget; money-path budgeting is not).
|
||
`ALTER TABLE checkpoints ADD COLUMN escalation INTEGER NOT NULL DEFAULT 0;`,
|
||
}
|
||
|
||
// migrate runs all pending migrations on the write pool, one transaction per
|
||
// step, recording each in schema_version.
|
||
func (s *Store) migrate(ctx context.Context) error {
|
||
if _, err := s.w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
|
||
return fmt.Errorf("store: schema_version: %w", err)
|
||
}
|
||
var current int
|
||
if err := s.w.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤t); err != nil {
|
||
return fmt.Errorf("store: read version: %w", err)
|
||
}
|
||
for v := current; v < len(migrations); v++ {
|
||
tx, err := s.w.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("store: begin migration %d: %w", v+1, err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, migrations[v]); err != nil {
|
||
_ = tx.Rollback()
|
||
return fmt.Errorf("store: apply migration %d: %w", v+1, err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (?)`, v+1); err != nil {
|
||
_ = tx.Rollback()
|
||
return fmt.Errorf("store: record migration %d: %w", v+1, err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return fmt.Errorf("store: commit migration %d: %w", v+1, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|