Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 KiB
identity Specification
Purpose
Как система идентифицирует сущности домена: ULID-ключи (канонический
lowercase-вид, нормализация и валидация на входных границах), множество
инфохэшей загрузки (download_infohash), инвариант «не более одной
активной загрузки на infohash» (дедупликация приёма, атомарный возврат в
активное состояние), корреляция сущностей в логах по id.
Requirements
Requirement: ULID как первичный ключ сущностей
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
Crockford base32, генерируемый приложением в момент создания записи через
единственную точку генерации (internal/ident). Сущности: download,
recognition, hint, override, metadata_candidate, file_link.
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
использоваться SHALL NOT. Идентификатор партии раскладки (apply_batch_id)
SHALL генерироваться тем же способом.
Scenario: Создание загрузки
- WHEN принимается новая загрузка
- THEN её
id— валидный ULID в lowercase - AND
idуникален глобально (не совпадает с id других сущностей)
Scenario: Хронологическая сортировка
- GIVEN две загрузки, созданные последовательно
- WHEN записи сортируются по
idлексикографически - THEN порядок совпадает с порядком создания
Requirement: Нормализация и валидация id на входных границах
Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
lowercase до обращения к хранилищу — это касается всех входных границ:
URL /download/{id}, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
несуществующая сущность (404 для страниц), без обращения к БД.
Scenario: Uppercase-вариант id в URL
- GIVEN существующая загрузка с id
01jz…(lowercase) - WHEN клиент открывает
/download/01JZ…(uppercase) - THEN открывается страница той же загрузки
Scenario: Мусор вместо id
- WHEN клиент открывает
/download/abc!!! - THEN ответ — 404, запрос к БД не выполняется
Requirement: Множество инфохэшей загрузки
Загрузка SHALL иметь одну или более записей инфохэша (download_infohash:
infohash lowercase hex, kind ∈ v1|v2). При приёме magnet-ссылки
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
btih (v1), и btmh (v2); kind определяется по длине hex (40 — v1, 64 —
v2). Когда qBittorrent сообщает для раздачи оба хеша (infohash_v1,
infohash_v2), система SHALL дописывать недостающие записи загрузке;
усечённый хеш v2-only раздачи (поле hash qBittorrent, 40 hex от v2)
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
приём после терминального состояния), но активной из них MUST быть не более
одной.
Scenario: Гибридный торрент раскрывает оба хеша
- GIVEN загрузка принята по magnet с v1-хешем
- WHEN qBittorrent отдаёт раздачу с заполненными
infohash_v1иinfohash_v2 - THEN у загрузки появляются обе записи (
kind=v1иv2)
Scenario: Сопоставление по v2-хешу
- GIVEN загрузка с записями v1- и v2-хешей
- WHEN поллинг находит раздачу, совпавшую только по v2-хешу
- THEN раздача сопоставляется с этой загрузкой
Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать активную (нетерминальную) загрузку по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
более одной активной загрузки на infohash». Отдельного снимаемого/
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
выводится только из state.
Scenario: Повторный приём при активной загрузке
- GIVEN активная загрузка с infohash
h - WHEN принимается magnet с тем же
h - THEN новая загрузка не создаётся, возвращается существующая
Scenario: Повторный приём после завершения
- GIVEN загрузка с infohash
hв терминальном состоянии (done) - WHEN принимается magnet с тем же
h - THEN создаётся новая загрузка со своим ULID и записью
h
Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути, возвращающем загрузку из терминального состояния в активное (ручной retry, воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её (приём, adopt чужой раздачи), что никакая другая активная загрузка не владеет любым из хешей этой, и при владении SHALL отказывать в переходе, сохраняя инвариант «не более одной активной загрузки на infohash». Отказ SHALL происходить до побочных эффектов во внешних системах (повторного добавления торрента в qBittorrent).
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие гибридного торрента): хеш, которым владеет другая активная загрузка, дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное состояние в обход этой проверки SHALL отклоняться хранилищем (механический бэкстоп вместо удалённого unique-индекса).
Scenario: Retry при занятом хеше
- GIVEN загрузка #1 в
failedс хешемh, и другая активная загрузка #2 с тем жеh - WHEN пользователь вызывает retry для #1
- THEN переход отклоняется с пояснением, #1 остаётся в
failed - AND активной по
hостаётся #2
Requirement: Корреляция сущностей в логах
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
<entity>_id (download_id, recognition_id, batch_id, …); работа в
контексте загрузки ведётся через scoped-логгер с download_id. Благодаря
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
все записи журнала, относящиеся к сущности, независимо от имени поля.
Scenario: Путь загрузки по логам
- GIVEN загрузка прошла приём, распознавание и раскладку
- WHEN журнал фильтруется по значению её
id - THEN находятся записи всех этапов (ingest, recognition, file-layout)
Requirement: Миграция существующих записей
Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
браться из created_at записи, чтобы лексикографический порядок новых id
соответствовал историческому порядку создания. Существующий
download.infohash SHALL быть перенесён в download_infohash
(нормализация к lowercase, kind по длине hex: 40 — v1, 64 — v2);
столбцы download.infohash и download.idempotency_key SHALL быть удалены.
Scenario: Связи и порядок после миграции
- GIVEN БД с загрузками, распознаваниями и файловыми ссылками на числовых id
- WHEN миграция выполнена
- THEN все FK-связи сохранены (распознавания/ссылки указывают на те же загрузки)
- AND порядок загрузок по
idсовпадает с порядком поcreated_at - AND каждый прежний
infohashпредставлен записью вdownload_infohash