Все сущности переехали с 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>
18 KiB
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.
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)
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)— проверка «нет активной загрузки с любым из хешей» (joindownload_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 и соблюдаем порядок:
- Прочитать строки старых таблиц в порядке старого
id(хронология). - Сгенерить маппинг
old int id → ULIDдля каждой таблицы: timestamp-часть — изcreated_atстроки (UTC в БД), entropy — черезulid.Monotonic-reader.created_atимеет секундное разрешение и дубли — норма (батчfile_link): monotonic-инкремент entropy при равном timestamp сохраняет относительный порядок старых id. Непарсибельныйcreated_at→ время миграции. - Создать новые таблицы (
*_new) родители первыми, дочерние — сREFERENCESна*_new-родителей; заливать данные тоже родители-первыми (FK включены — порядок обязателен).download.infohashразносится вdownload_infohash_new(lowercase,kindпо длине hex);idempotency_keyиdownload.infohashопускаются. DROPстарых таблиц дети первыми, затемALTER TABLE … RENAME(*_new→ канонические имена; SQLite ≥ 3.25 переписываетREFERENCESв ссылающихся таблицах при переименовании), пересоздать индексы.- Финальный
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
- Код + миграция в одном бинаре; goose прогоняет миграцию на старте, как обычно.
- Перед деплоем на umbar — ручная копия SQLite-файла (data-том).
- Откат = восстановить копию файла + прежний бинарь (совместимость схем вниз не поддерживаем).
Open Questions
- Нет блокирующих. Мелочь на реализацию:
hint/override/metadata_candidateнигде не светятся наружу — их ULID нужны только для единообразия и логов, отдельных требований не несут.