Идентичность на 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,240 @@
## Context
Идентичность сегодня: `download.id` — INTEGER AUTOINCREMENT (как и у всех
шести таблиц), дедуп — `idempotency_key UNIQUE` (= infohash у активных
задач). Ключ снимается при терминализации (`SetDownloadState`:
`idempotency_key = CASE WHEN terminal THEN NULL ELSE infohash END`) и
восстанавливается при возврате из терминала — так обеспечивается инвариант
«одна активная задача на infohash» при легальном повторном приёме того же
торрента после завершения. Поллинг сопоставляет раздачу по `hash`/
`infohash_v1`/`infohash_v2` с одним хранимым `download.infohash`.
Мотивация и разбор — в proposal и черновике
[logical-title-model §5.1](../../../docs/drafts/logical-title-model.md).
## Goals / Non-Goals
**Goals:**
- ULID как публичный стабильный ключ всех сущностей домена; единый формат id
в БД, URL и логах; grep по голому id находит всё.
- Множество инфохэшей загрузки (`download_infohash`) вместо одного столбца;
дедуп и сопоставление в поллинге — по любому из хешей.
- Упрощение механики активности: убрать снимаемый/восстанавливаемый
`idempotency_key`, активность выводится только из `state`.
- Конвенция «без числовых PK» для будущих таблиц.
**Non-Goals:**
- Сходимость папки, merge-раскладка, `state_transition`, сущность title —
отдельные change'и (этапность черновика).
- Оптимизация производительности БД (домашний масштаб).
- Совместимость со старыми числовыми id после миграции (старые URL в
истории Telegram/закладках, старые inline-кнопки бота) — не поддерживаем.
## Decisions
### D1. ULID, канонически lowercase
ULID (`github.com/oklog/ulid/v2`, чистый Go): 128 бит, сортируем по времени
(48 бит timestamp), 26 символов Crockford base32 — компактнее UUID, без
дефисов (grep/двойной клик в логах), глобально уникален across таблиц.
Альтернативы: UUIDv4 — не сортируется; UUIDv7 — эквивалент со статусом RFC,
выбрали бы при интеграции с внешней системой, ждущей UUID (таких нет);
xid/KSUID — менее распространены без выгоды.
Канонический вид — **lowercase** (читаемость; спека ULID case-insensitive
при декодировании). Единственная точка генерации — хелпер `internal/ident`:
`NewID()` (monotonic entropy, `strings.ToLower`), `Parse()` (нормализация
регистра + валидация). Id сущностей генерируют Create-методы `store`
(сейчас они живут на `LastInsertId` — все переводятся на `ident.NewID()`);
`apply_batch_id` генерирует воркер тем же хелпером. Все входные границы
(URL, формы — включая `candidate_id` в ревью) прогоняют id через `Parse`
до запроса к БД — сравнение в SQLite побайтовое.
### D2. Хранение: TEXT PK, обычные rowid-таблицы
TEXT(26), не BLOB(16) — читаемость в `sqlite3` CLI и логах дороже 10 байт.
`WITHOUT ROWID` не используем — выгода на нашем масштабе нулевая, а готчи
есть. `AUTOINCREMENT` исчезает из схемы полностью.
### D3. `download_infohash`: составной ключ, а НЕ `UNIQUE(infohash)`
```sql
CREATE TABLE download_infohash (
download_id TEXT NOT NULL REFERENCES download (id) ON DELETE CASCADE,
infohash TEXT NOT NULL, -- lowercase hex
kind TEXT NOT NULL, -- v1 | v2
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (infohash, download_id)
);
```
Черновик предлагал `UNIQUE(infohash)` — это **неверно**: спека
state-reconciliation гарантирует «повторный приём того же infohash после
терминала возможен», то есть один хеш легитимно принадлежит нескольким
загрузкам во времени. Глобальная уникальность действует только среди
**активных** загрузок, а это условие на `download.state` — в индекс SQLite
не выразить. PK `(infohash, download_id)` даёт и дедуп строк, и индекс для
поиска по хешу.
### D4. Инвариант «одна активная загрузка на infohash» — двумя атомарными операциями store
`idempotency_key` и его CASE-восстановление удаляются; активность — чисто
функция `state` (terminalStates).
**Критично:** сегодня финальный backstop инварианта — partial unique index
по `idempotency_key`, и на него опираются **пять** путей записи (комментарии
в коде прямо ссылаются на индекс): приём (`ingest`), adopt чужого торрента
(`worker/discover.go`), ручной `Retry` (`worker.go`), воскрешение сверкой
(`reconcile.go:reconcileOneRecovery`), `Relink` (`review.go`). Ни один из
них сейчас не делает check+write в одной транзакции — гонку закрывал индекс.
С удалением индекса **все пять** обязаны пройти через атомарные операции.
Вводим два guarded-метода `store`, каждый — одна write-транзакция
(`BEGIN IMMEDIATE`; SQLite сериализует писателей, поэтому check-then-write
внутри одной write-tx гонок не имеет):
- `CreateDownloadIfNoActive(d, hashes)` — проверка «нет активной загрузки с
любым из хешей» (join `download_infohash` × `state`) → вставка `download`
+ хешей; иначе возвращает существующую активную (семантика дедупа приёма),
дописав ей недостающие хеши из вызова (второй хеш гибрида не теряется).
Используют ingest и discover-adopt.
- `ActivateIfNoOtherActive(id, toState, …)` — проверка «никакая ДРУГАЯ
активная загрузка не владеет любым из хешей этой» (сама задача исключена
из выборки — stuck-задача при retry активна и не должна маскировать
чужого владельца) → переход состояния; иначе отказ. Используют Retry,
recovery-воскрешение, Relink; отказ — ДО побочных эффектов (Retry
активирует до повторного qbt.Add, при сбое Add откатывает состояние).
- `AddInfohashes(id, hashes)` — дозапись хешей (раскрытие гибрида) под тем
же гардом: хеш чужой активной задачи не дописывается (ErrInfohashTaken).
Механический бэкстоп вместо удалённого unique-индекса: `SetDownloadState`
отклоняет переход терминал→активное (предикат в UPDATE) — оживление идёт
только через `ActivateIfNoOtherActive`.
`FindActiveByInfohash`/`ExistsByInfohash` переезжают на join по
`download_infohash` и остаются для чтения (не как гард).
### D5. Накопление хешей из поллинга
Magnet-парсер извлекает btih (v1) **и** btmh (v2) гибридной ссылки
(`Info.Infohashes`, v1 первым) — приём записывает ВСЕ известные хеши,
`kind` — по длине hex: 40 = v1, 64 = v2 (не хардкодить v1). Когда
qBittorrent отдаёт торрент с заполненными `infohash_v1`/`infohash_v2`,
поллинг дописывает недостающие строки через guarded `AddInfohashes`.
Сборщик хешей торрента один — `torrentHashes`: поле `hash` qBittorrent
берётся только при пустых v1/v2 (старый API), потому что у v2-only раздач
это УСЕЧЁННЫЙ v2 (40 hex, по длине неотличим от v1) — его не храним, а
SourceRef усыновления строится из полноразмерного хеша (btih/btmh).
Сопоставление раздачи — по любому из хешей загрузки; live-карта воркера
уже ключуется всеми формами хеша торрента, модель с ней совместима.
### D6. Миграция: одна Go-миграция goose
Первая Go-миграция в проекте (до сих пор — только SQL-файлы из embed).
Механизм: goose поддерживает смешение — classic API (`goose.SetBaseFS` +
`goose.Up`) подхватывает и зарегистрированные Go-миграции. Регистрация —
`goose.AddMigrationContext` в `init()` пакета `store/migrations`, файл
`0006_*.go`; пакет становится Go-пакетом и должен быть импортирован из
`store.go` (иначе `init()` не выполнится). SQL не может генерить ULID —
поэтому Go.
Вся миграция — в одной транзакции goose. Важно: `PRAGMA foreign_keys=OFF`
внутри транзакции — тихий no-op в SQLite, а DSN включает FK на каждом
соединении, поэтому **работаем с включёнными FK** и соблюдаем порядок:
1. Прочитать строки старых таблиц **в порядке старого `id`** (хронология).
2. Сгенерить маппинг `old int id → ULID` для каждой таблицы:
**timestamp-часть — из `created_at` строки** (UTC в БД), entropy — через
`ulid.Monotonic`-reader. `created_at` имеет секундное разрешение и
дубли — норма (батч `file_link`): monotonic-инкремент entropy при равном
timestamp сохраняет относительный порядок старых id. Непарсибельный
`created_at` → время миграции.
3. Создать новые таблицы (`*_new`) **родители первыми**, дочерние — с
`REFERENCES` на `*_new`-родителей; заливать данные тоже родители-первыми
(FK включены — порядок обязателен). `download.infohash` разносится в
`download_infohash_new` (lowercase, `kind` по длине hex);
`idempotency_key` и `download.infohash` опускаются.
4. `DROP` старых таблиц **дети первыми**, затем `ALTER TABLE … RENAME`
(`*_new` → канонические имена; SQLite ≥ 3.25 переписывает `REFERENCES`
в ссылающихся таблицах при переименовании), пересоздать индексы.
5. Финальный `PRAGMA foreign_key_check` как самопроверка.
### D7. Границы: URL, ссылки, логи
- `httpapi`: парсинг `{id}` централизован в `pathID` — там `ident.Parse`
вместо `strconv.ParseInt`; невалидный id → 404 без похода в БД. Вне
`{id}`-роутов: `candidate_id` из формы ревью, сентинелы `downloadID > 0`
в `errBody`/`userErr` (со string — `!= ""`). **BREAKING для REST JSON**:
поле `id` в DTO меняет тип `number → string`.
- Telegram: ссылки бота ведут на `/review/{id}` (не только `/download/`),
callback-data содержит id (`parseCallback` через `strconv.ParseInt`,
сентинел `id == 0`, `pending map[int64]int64`) — всё переводится на
string/ULID. Старые сообщения: числовые URL отдадут 404, нажатие старой
inline-кнопки должно получать понятный ответ «кнопка устарела», а не
панику/тишину.
- Логи: поле `<entity>_id` у каждой сущности (`download_id`,
`recognition_id`, `batch_id`); scoped-логгер уже есть; grep по голому ULID
— штатный способ корреляции наравне с jq.
- Сортировки: списки сегодня сортируются `ORDER BY id DESC`
`COALESCE(source_added_at, created_at), id`) — с ULID это остаётся
корректным благодаря сортируемости и хронологическому бэкфиллу; менять
запросы не требуется.
### D8. Экспозиция множества хешей наружу
`download.infohash` читают не только дедуп и поллинг: карточка загрузки и
кнопка копирования (требование web-ui), REST DTO, live-лукап
(`Live(d.Infohash)`), scoped-логгеры воркера, поиск в списке
(`listWhere … infohash LIKE`). Решения:
- Модель `Download` дополняется срезом хешей, подгружаемым вместе с записью
(или методом `store`); карточка и REST показывают **все** хеши загрузки
(v1 и v2, каждый с копированием); REST-поле `infohash` заменяется на
`infohashes` (список).
- Live-лукап — по любому из хешей (live-карта воркера уже ключуется всеми
формами хеша торрента).
- Scoped-логгер кладёт в поле `infohash` первый известный хеш (для
корреляции этого достаточно — id теперь главный ключ поиска по логам).
- Поиск в списке — `EXISTS`-подзапрос по `download_infohash` вместо
`LIKE` по удаляемому столбцу.
### D9. Конвенция
Новый `docs/conventions/database.md`: PK — TEXT ULID, генерится приложением
через `internal/ident`; числовой AUTOINCREMENT не используем; у
деталей/связей допустим естественный/составной ключ; канонический вид id —
lowercase, нормализация на входных границах. Ссылки — из README конвенций и
CLAUDE.md. ER-схема `docs/specs/database.md` обновляется в том же change.
## Risks / Trade-offs
- [Гонка дедупа без UNIQUE-гарда] → check-then-insert строго в одной
write-транзакции (`BEGIN IMMEDIATE`), SQLite сериализует писателей.
- [Ошибка миграции портит данные] → миграция в транзакции goose
(SQLite умеет транзакционный DDL); перед деплоем — копия файла БД
(штатный бэкап на umbar пока не автоматизирован — сделать руками).
- [Старые URL в истории Telegram/закладках и старые inline-кнопки ломаются]
→ принято: домашний сервис, история коротка; редиректов со старых числовых
id не делаем; на устаревшую callback-data бот отвечает понятной ошибкой.
- [`created_at` непарсибелен] → fallback на время миграции, порядок ULID
внутри таблицы всё равно монотонен (entropy-инкремент).
- [Коллизия ULID] → 80 бит энтропии на миллисекунду, единственный генератор
в одном процессе — пренебрежимо.
- [Первая Go-миграция усложняет store/migrations] → цена принята: паттерн
понадобится и дальше (backfill-миграции), закладываем аккуратно.
## Migration Plan
1. Код + миграция в одном бинаре; goose прогоняет миграцию на старте, как
обычно.
2. Перед деплоем на umbar — ручная копия SQLite-файла (data-том).
3. Откат = восстановить копию файла + прежний бинарь (совместимость схем
вниз не поддерживаем).
## Open Questions
- Нет блокирующих. Мелочь на реализацию: `hint`/`override`/
`metadata_candidate` нигде не светятся наружу — их ULID нужны только для
единообразия и логов, отдельных требований не несут.