Все сущности переехали с 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>
157 lines
11 KiB
Markdown
157 lines
11 KiB
Markdown
# identity — идентичность сущностей домена
|
||
|
||
Как система идентифицирует сущности: ULID-ключи и их канонический вид,
|
||
множество инфохэшей загрузки, дедупликация приёма, корреляция в логах.
|
||
|
||
## ADDED 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`
|