- Раскладка docs/ приведена к канону 2: заведены passport/architecture/ database/security/review и research; docs/specs, drafts, backlog, review/ и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи, 6 целей, слаги на английский). - Нарративы specs удалены как дубли openspec-спек после поимённой сверки; остаток заведён задачами (редактор маппинга ревью, крайние случаи именования), отказ от сущности title промоутнут в ADR. - Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon вместо er-schema.
212 lines
14 KiB
Markdown
212 lines
14 KiB
Markdown
# Схема хранилища
|
||
|
||
Актуальная схема SQLite: таблицы, поля и связи. Это **живой** документ — его
|
||
поддерживаем в соответствии с миграциями.
|
||
|
||
> **Поддержка вместе с миграциями.** Источник истины по схеме —
|
||
> `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции,
|
||
> меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму
|
||
> в том же change. Расхождение схемы с миграциями считаем багом документации;
|
||
> его же ловит `docs.py check` в гейте.
|
||
>
|
||
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
||
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
|
||
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
|
||
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
|
||
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла),
|
||
> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON).
|
||
|
||
Назначение таблиц и роль компонентов — [architecture.md](architecture.md).
|
||
Значения `state` и легальные переходы — нормативно в
|
||
[download-tracking](../openspec/specs/download-tracking/spec.md) и
|
||
[state-reconciliation](../openspec/specs/state-reconciliation/spec.md).
|
||
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
|
||
(`internal/ident`) — см. [конвенцию](conventions/database.md). Метки времени
|
||
(`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет
|
||
приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках.
|
||
|
||
## ER-диаграмма
|
||
|
||
```mermaid
|
||
erDiagram
|
||
download ||--o{ download_infohash : "инфохэши (v1/v2)"
|
||
download ||--o| download_torrent : "байты .torrent (1:0..1)"
|
||
download ||--o{ recognition : "распознавания"
|
||
download ||--o{ hint : "подсказки"
|
||
download ||--o{ override : "ручные правки"
|
||
download ||--o{ file_link : "хардлинки"
|
||
recognition ||--o{ metadata_candidate : "кандидаты базы"
|
||
|
||
download {
|
||
TEXT id PK "ULID (lowercase), генерится приложением"
|
||
TEXT source_type "NOT NULL; magnet|torrent|url"
|
||
TEXT source_ref "NOT NULL; magnet/url/путь"
|
||
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
|
||
TEXT context "NOT NULL DEFAULT ''"
|
||
TEXT parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)"
|
||
TEXT state "NOT NULL; активность выводится только из state"
|
||
TEXT error_code "nullable"
|
||
TEXT error_msg "nullable"
|
||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
||
TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)"
|
||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
}
|
||
|
||
download_infohash {
|
||
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)"
|
||
TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)"
|
||
TEXT kind "NOT NULL; v1|v2"
|
||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
}
|
||
|
||
download_torrent {
|
||
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; байты source_type=torrent"
|
||
BLOB data "NOT NULL; исходные байты .torrent для добавления файлом (миграция 0009)"
|
||
}
|
||
|
||
recognition {
|
||
TEXT id PK "ULID"
|
||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||
INTEGER attempt_no "NOT NULL DEFAULT 1"
|
||
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
|
||
TEXT media_type "nullable; movie|series"
|
||
TEXT title "nullable"
|
||
TEXT original_title "nullable"
|
||
INTEGER year "nullable"
|
||
TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
|
||
TEXT provider_id "nullable"
|
||
REAL confidence "nullable"
|
||
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
|
||
TEXT raw_llm "nullable; сырой ответ LLM"
|
||
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
|
||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
}
|
||
|
||
hint {
|
||
TEXT id PK "ULID"
|
||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||
TEXT text "NOT NULL"
|
||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
}
|
||
|
||
override {
|
||
TEXT id PK "ULID"
|
||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||
TEXT field "NOT NULL; UNIQUE(download_id, field)"
|
||
TEXT value "NOT NULL"
|
||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
}
|
||
|
||
metadata_candidate {
|
||
TEXT id PK "ULID"
|
||
TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE"
|
||
TEXT provider "NOT NULL"
|
||
TEXT provider_id "NOT NULL"
|
||
TEXT title "nullable"
|
||
INTEGER year "nullable"
|
||
TEXT url "nullable; ссылка на страницу на сайте провайдера"
|
||
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
}
|
||
|
||
file_link {
|
||
TEXT id PK "ULID"
|
||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
|
||
TEXT src_path "NOT NULL; исходный файл раздачи"
|
||
TEXT dst_path "NOT NULL; целевой хардлинк"
|
||
TEXT kind "NOT NULL; video|subtitle|..."
|
||
TEXT status "NOT NULL; linked|copied|exists|collision|superseded"
|
||
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
|
||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||
}
|
||
```
|
||
|
||
## Связи и кардинальность
|
||
|
||
- `download` 1 — N `download_infohash` / `recognition` / `hint` / `override`
|
||
/ `file_link`; `recognition` 1 — N `metadata_candidate`. Все дочерние — с
|
||
`ON DELETE CASCADE`: удаление загрузки уносит её хеши, распознавания,
|
||
подсказки, правки и ссылки.
|
||
- `download_infohash` — множество хешей одной загрузки (v1/v2 гибридного
|
||
торрента); один и тот же infohash может принадлежать нескольким загрузкам
|
||
во времени (повторный приём после терминального состояния). Инвариант «не
|
||
более одной активной загрузки на infohash» держат guarded-методы store
|
||
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
|
||
write-транзакции — на уровне схемы он не выражается (условие на `state`).
|
||
- `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только
|
||
у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для
|
||
повторного добавления при retry, поэтому живут весь срок строки загрузки.
|
||
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
||
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
||
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
||
субтитры); ссылки могут накапливаться несколькими `apply_batch_id`.
|
||
|
||
## Индексы и ограничения
|
||
|
||
- `download`: индекс по `state`.
|
||
- `download_infohash`: PK `(infohash, download_id)` (он же индекс поиска по
|
||
хешу); индекс по `download_id`.
|
||
- `recognition`: индекс по `download_id`.
|
||
- `override`: `UNIQUE(download_id, field)`.
|
||
- `metadata_candidate`: индекс по `recognition_id`.
|
||
- `file_link`: индексы по `download_id` и по `apply_batch_id`.
|
||
|
||
> Enum-поля (`source_type`, `state`, `provider`, `kind`, `status`, флаги
|
||
> `0/1`) на уровне SQLite — обычный `TEXT`/`INTEGER` без `CHECK`; допустимые
|
||
> значения держит код (`internal/store`).
|
||
|
||
## Представление данных
|
||
|
||
Чем физически лежит запись и что происходит при чтении и записи.
|
||
|
||
- **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких
|
||
внешних файлов: строка читается и пишется целиком обычным запросом.
|
||
- **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический
|
||
`recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (список
|
||
причин не-авто) и `download.parsed_context` (структура имени из контекста).
|
||
Читаются целиком и разбираются в Go; частичного чтения и обновления поля
|
||
внутри JSON нет, SQL по содержимому этих полей не делается.
|
||
- **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это
|
||
самое крупное поле в базе и главный кандидат на рост: у каждой попытки
|
||
распознавания свой ответ, попытки не вытесняются, ретеншена нет
|
||
(задача в беклоге).
|
||
- **`download_torrent.data`** — единственный BLOB: исходные байты `.torrent`
|
||
(обычно десятки КБ, у больших раздач — сотни). Читается целиком при
|
||
добавлении в qBittorrent и при retry.
|
||
- **Истории переходов нет** — хранится только текущий `state`; «как сюда
|
||
попали» восстанавливается по логам (задача в беклоге).
|
||
- **Терминальные загрузки не удаляются**, `file_link` со статусом `superseded`
|
||
тоже остаются — база монотонно растёт по числу обработанных раздач.
|
||
|
||
## Настройки с числовым значением
|
||
|
||
СУБД (`internal/store`, DSN при открытии):
|
||
|
||
| Настройка | Значение | Зачем |
|
||
| --- | --- | --- |
|
||
| `journal_mode` | `WAL` | читатели не блокируют писателя |
|
||
| `busy_timeout` | 5000 мс | ждать снятия блокировки, а не падать сразу `database is locked` |
|
||
| `foreign_keys` | `ON` | `ON DELETE CASCADE` работает только с этим |
|
||
| `_txlock` | `immediate` | явная транзакция открывается как write с самого начала; на этом держатся guarded-методы инварианта «одна активная загрузка на infohash» |
|
||
| Размер пула | по умолчанию `database/sql` | явно не ограничен; писателя SQLite сериализует сама |
|
||
|
||
Времена и пороги, влияющие на объём и частоту работы с базой (значения по
|
||
умолчанию, `config.example.toml` — источник истины по полям):
|
||
|
||
| Параметр | По умолчанию | Что означает |
|
||
| --- | --- | --- |
|
||
| `[worker].poll_interval` | `5s` | частота опроса qBittorrent, а значит и фонового чтения/записи состояния |
|
||
| `[worker].stuck_after` | `1h` | простой раздачи, после которого она считается зависшей |
|
||
| `[worker].magnet_timeout` | `24h` | страховочный предел ожидания метаданных magnet |
|
||
| `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent |
|
||
| `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника |
|
||
| `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) |
|
||
| `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
|
||
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе |
|
||
|
||
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
|
||
кэша метабаз нет — всё три пункта в беклоге.
|