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

18 KiB
Raw Blame History

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) — проверка «нет активной загрузки с любым из хешей» (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 DESCCOALESCE(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 нужны только для единообразия и логов, отдельных требований не несут.