Files
jellybit/openspec/changes/archive/2026-07-02-ulid-identity/design.md
T
avandClaude Fable 5 37f2f6481a Идентичность на 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>
2026-07-02 21:25:00 +03:00

241 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 нужны только для
единообразия и логов, отдельных требований не несут.