Все сущности переехали с 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>
241 lines
18 KiB
Markdown
241 lines
18 KiB
Markdown
## 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 нужны только для
|
||
единообразия и логов, отдельных требований не несут.
|