Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)

Все сущности переехали с 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>
This commit is contained in:
av
2026-07-02 21:25:00 +03:00
co-authored by Claude Fable 5
parent b808ceff25
commit 37f2f6481a
53 changed files with 3640 additions and 1035 deletions
@@ -0,0 +1,81 @@
## 1. Фундамент: пакет ident
- [x] 1.1 Добавить зависимость `github.com/oklog/ulid/v2`; пакет
`internal/ident`: `NewID()` (lowercase, monotonic entropy,
потокобезопасно), `NewIDAt(t time.Time)` (для миграции/бэкфилла),
`Parse(s)` (нормализация регистра + валидация); тесты
(lowercase, сортируемость, отказ на мусоре)
## 2. Схема и миграция
- [x] 2.1 Механизм Go-миграций goose в `internal/store/migrations`
(регистрация через `goose.AddMigrationContext`, совместный прогон с
embed SQL-миграциями)
- [x] 2.2 Миграция 0006: новые таблицы с TEXT ULID PK (все шесть), перенос
данных с маппингом `int → ULID` (timestamp из `created_at`, fallback —
время миграции), перенос `download.infohash``download_infohash`
(lowercase, `kind` по длине hex), удаление `download.infohash` и
`download.idempotency_key`, пересоздание индексов
- [x] 2.3 Тест миграции на фикстурной БД: FK-связи сохранены, порядок по
`id` = порядок по `created_at`, хеши разнесены, `idempotency_key`
отсутствует
## 3. Store
- [x] 3.1 Типы id `int64 → string` во всех структурах и методах `store`
(download, recognition, hint, override, metadata_candidate,
file_link, list); генерация ULID через `ident.NewID()` во ВСЕХ
Create-методах (вместо `LastInsertId`)
- [x] 3.2 Guarded-методы инварианта (design D4):
`CreateDownloadIfNoActive` и `ActivateIfNoOtherActive`, каждый — одна
write-транзакция; `FindActiveByInfohash`/`ExistsByInfohash` join'ом
по любому хешу (для чтения); убрать CASE-восстановление
`idempotency_key` из `SetDownloadState`
- [x] 3.3 Методы хешей: добавить недостающие хеши загрузке
(INSERT OR IGNORE), получать хеши вместе с Download (срез в модели)
- [x] 3.4 Поиск в списке (`listWhere`): `EXISTS`-подзапрос по
`download_infohash` вместо `LIKE` по удаляемому `download.infohash`
## 4. Ядро и воркер
- [x] 4.1 Приём (`ingest`) и discover-adopt — через
`CreateDownloadIfNoActive`; хеш из magnet (btih ИЛИ btmh, `kind` по
длине hex) пишется в той же транзакции; сигнатуры
`Result.DownloadID`, `notifyFailed`, `Notifier.Notify`,
`failNotified` — на string
- [x] 4.2 Поллинг/сверка: сопоставление раздачи по любому из хешей загрузки
(Poll, desync, recovery, preflight); дописывание недостающих v1/v2,
когда qBittorrent отдаёт оба; scoped-логгеры — первый известный хеш
- [x] 4.3 Retry, recovery-воскрешение и Relink — через
`ActivateIfNoOtherActive` (сейчас гонку закрывал unique-индекс —
см. design D4); понятная ошибка при занятом хеше
- [x] 4.4 `apply_batch_id` генерировать через `ident.NewID()`
## 5. Внешние границы
- [x] 5.1 `httpapi`: `ident.Parse` в `pathID` (невалидный → 404 без похода
в БД) и для `candidate_id` из формы ревью; сентинелы
`downloadID > 0 → != ""`; REST DTO: `id` string, `infohash`
список `infohashes`; карточка показывает все хеши с копированием;
live-лукап по любому хешу; проверить шаблоны и ссылки
- [x] 5.2 Telegram (`tgbot`): `parseCallback` и callback-data на string-id,
`pending map[int64]int64 → map[int64]string`, сентинел `id == 0 →
== ""`, ссылки `/review/{id}`; понятный ответ на устаревшую
callback-data со старым числовым id
## 6. Логи и документация
- [x] 6.1 Атрибуты `<entity>_id` в логах: `recognition_id` у попыток
распознавания, `batch_id` у раскладки; сверить с scoped-логгером
- [x] 6.2 `docs/conventions/logging.md`: примеры id в формате ULID, grep по
голому id как штатная корреляция
- [x] 6.3 Новая `docs/conventions/database.md` (TEXT ULID PK, без
AUTOINCREMENT, естественные ключи у деталей, lowercase + нормализация);
ссылки из `docs/conventions/README.md` и `CLAUDE.md`
- [x] 6.4 ER-схема `docs/specs/database.md`: ULID PK, `download_infohash`,
удалённые столбцы
## 7. Проверка
- [x] 7.1 `task test` и `task lint` зелёные; ручной прогон: приём magnet →
дедуп повторного приёма → страница `/download/{id}` с ULID в URL