- имя раздачи нормализуется на границе разбора: вырожденное `-` (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки вывода имени - контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и удерживается структурно; три транспорта перестали обещать идентификатор, которого нет, и коррелируют отказ по request_id - scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах воркера — записи об отказе qBittorrent и метабаз получили download_id и infohash; граница разбора bencode записана в docs/research
119 lines
8.5 KiB
Markdown
119 lines
8.5 KiB
Markdown
# identity Specification
|
||
|
||
## Purpose
|
||
|
||
Как система идентифицирует сущности домена: ULID-ключи (канонический
|
||
lowercase-вид, нормализация и валидация на входных границах) и корреляция
|
||
сущностей в логах по id. Инфохэши загрузки (`download_infohash`),
|
||
дедупликация приёма и инвариант «не более одной активной загрузки на
|
||
infohash» (атомарный возврат в активное состояние) — в capability `ingest`.
|
||
## 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 содержать её id в атрибуте
|
||
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
|
||
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
|
||
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
|
||
все записи журнала, относящиеся к сущности, независимо от имени поля.
|
||
|
||
Scoped-логгер SHALL передаваться через `context`, а не доклеиваться к каждой
|
||
записи руками. Отсюда обязанность вызывающего, и она ограничена наблюдаемым
|
||
исходом: **операция, работающая в контексте загрузки и делающая вызов внешнего
|
||
сервиса, SHALL положить scoped-логгер этой загрузки в `context` до такого
|
||
вызова** — включая команды, пришедшие с транспорта, а не только фоновый цикл
|
||
воркера. Однородность формы у команд, внешних вызовов не делающих, это
|
||
требование не нормирует: она принадлежит конвенциям кода.
|
||
|
||
Причина в том, что клиент внешнего сервиса своей доменной сущности не знает и
|
||
знать SHALL NOT — он берёт логгер из `context`. Поэтому вызов внешнего сервиса в
|
||
контексте загрузки SHALL давать запись с `download_id` и, когда он известен,
|
||
`infohash`; добавлять клиенту поля-дубликаты доменных идентификаторов ради этого
|
||
SHALL NOT — источник корреляции один.
|
||
|
||
Перечень клиентов, ведущих записи о внешних вызовах, живёт в
|
||
`docs/conventions/logging.md` и здесь не дублируется. Telegram-клиент таких
|
||
записей не ведёт, и уведомление отправляется вне контекста загрузки намеренно
|
||
(иначе оно умирало бы вместе с тиком) — это требование его не касается.
|
||
|
||
Отдельно это важно там, где внешний сервис не сообщает причину отказа: ответ
|
||
qBittorrent `Fails.` на добавление раздачи причины не несёт, и единственное, что
|
||
делает такую запись пригодной для разбора, — корреляция с загрузкой.
|
||
|
||
#### Scenario: Путь загрузки по логам
|
||
|
||
- **GIVEN** загрузка прошла приём, распознавание и раскладку
|
||
- **WHEN** журнал фильтруется по значению её `id`
|
||
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)
|
||
|
||
#### Scenario: Неуспешное добавление в qBittorrent с пути retry
|
||
|
||
- **GIVEN** загрузка в `failed` с известным инфохэшем, для которой оператор
|
||
запросил retry
|
||
- **WHEN** qBittorrent отвечает на добавление отказом (`Fails.` либо не-200)
|
||
- **THEN** запись о вызове внешнего сервиса содержит `download_id` и `infohash`
|
||
загрузки
|
||
- **AND** запись находится тем же фильтром по значению id, что и записи
|
||
фонового пути добавления
|
||
|
||
### 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`
|
||
|