- Раскладка 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.
14 KiB
Схема хранилища
Актуальная схема 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.
Значения state и легальные переходы — нормативно в
download-tracking и
state-reconciliation.
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
(internal/ident) — см. конвенцию. Метки времени
(created_at/updated_at) — TEXT в RFC 3339, UTC (суффикс Z); пишет
приложение (store.Now/FormatTime), без DEFAULT на колонках.
ER-диаграмма
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), пишет приложение"
}
Связи и кардинальность
download1 — Ndownload_infohash/recognition/hint/override/file_link;recognition1 — Nmetadata_candidate. Все дочерние — сON DELETE CASCADE: удаление загрузки уносит её хеши, распознавания, подсказки, правки и ссылки.download_infohash— множество хешей одной загрузки (v1/v2 гибридного торрента); один и тот же infohash может принадлежать нескольким загрузкам во времени (повторный приём после терминального состояния). Инвариант «не более одной активной загрузки на infohash» держат guarded-методы store (CreateDownloadIfNoActive/ActivateIfNoOtherActive) в одной write-транзакции — на уровне схемы он не выражается (условие наstate).download1 — 0..1download_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 нет, кэша метабаз нет — всё три пункта в беклоге.