Files
av 42d5b73a04 docs: перевод документации на канон av-dev
- Раскладка 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.
2026-08-04 09:27:26 +03:00

14 KiB
Raw Permalink Blame History

Схема хранилища

Актуальная схема 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), пишет приложение"
    }

Связи и кардинальность

  • 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, поэтому живут весь срок строки загрузки.
  • downloadfile_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 нет, кэша метабаз нет — всё три пункта в беклоге.