Идентичность на 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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-02
|
||||
@@ -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 нужны только для
|
||||
единообразия и логов, отдельных требований не несут.
|
||||
@@ -0,0 +1,69 @@
|
||||
## Why
|
||||
|
||||
Идентичность в домене сегодня держится на двух хрупких вещах: загрузка
|
||||
фактически идентифицируется инфохэшем (`idempotency_key`), хотя у одной
|
||||
логической загрузки хешей несколько (v1/v2/гибрид, перезалив — другой хеш),
|
||||
а первичные ключи всех таблиц — числовые автоинкременты, не уникальные между
|
||||
таблицами и неудобные для корреляции в логах. Это фундамент (шаг 1 черновика
|
||||
[logical-title-model](../../../docs/drafts/logical-title-model.md)) для
|
||||
«второго сезона», «докачивания» и истории переходов; менять PK дешевле
|
||||
сейчас, пока БД маленькая и ссылок на идентификатор немного.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **ULID (канонически lowercase) как TEXT PK всех сущностей**: `download`,
|
||||
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
|
||||
Генерация — в приложении (`oklog/ulid`, monotonic entropy). **BREAKING**:
|
||||
формат id меняется в URL (`/download/{id}`, `/review/{id}`), ссылках и
|
||||
callback-data Telegram-бота, логах; в REST JSON поле `id` меняет тип
|
||||
`number → string`, поле `infohash` заменяется списком `infohashes`.
|
||||
- **Новая таблица `download_infohash`** (`download_id`, `infohash`,
|
||||
`kind` v1|v2, составной PK `(infohash, download_id)` — один хеш легитимно
|
||||
принадлежит нескольким загрузкам во времени) — множество хешей одной
|
||||
загрузки; дедуп переезжает на проверку активности по этой таблице,
|
||||
столбцы `download.idempotency_key` и `download.infohash` удаляются.
|
||||
- **Поиск по любому из хешей** — при приёме (дедуп) и в поллинге qBittorrent.
|
||||
- `apply_batch_id` генерируется как ULID (столбец уже TEXT).
|
||||
- **Go-миграция goose**: backfill ULID существующим строкам с timestamp-частью
|
||||
из `created_at` (сортировка id сохраняет хронологию), переписывание FK,
|
||||
разнос текущего `infohash` в `download_infohash`.
|
||||
- **Конвенция `docs/conventions/database.md`**: PK — TEXT ULID, генерится
|
||||
приложением; числовой AUTOINCREMENT не используем; у деталей/связей
|
||||
допустим естественный ключ.
|
||||
- **Логи**: у каждой сущности поле `<entity>_id`; глобальная уникальность
|
||||
ULID делает grep по голому id штатным способом корреляции; обновить
|
||||
примеры в `docs/conventions/logging.md`.
|
||||
- ER-схема `docs/specs/database.md` обновляется в этом же change.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `identity`: как система идентифицирует сущности домена — ULID-ключи и их
|
||||
канонический вид (нормализация на входных границах), множество инфохэшей
|
||||
загрузки, инвариант «одна активная загрузка на infohash» (дедуп при приёме),
|
||||
сопоставление раздачи в поллинге по любому из хешей.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `state-reconciliation`: требование «терминализация восстанавливает
|
||||
`idempotency_key`» меняется — инвариант «одна активная задача на infohash»
|
||||
обеспечивается проверкой активности по `download_infohash`, отдельный
|
||||
снимаемый/восстанавливаемый ключ исчезает.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код**: `store` (типы id `int64 → string`, все запросы, миграция),
|
||||
`ingest` (дедуп через `download_infohash`), `worker` (сопоставление в
|
||||
поллинге по множеству хешей), `httpapi`/веб-UI (парсинг и валидация ULID в
|
||||
`/download/{id}`, ссылки), Telegram-уведомления (ссылки на загрузку).
|
||||
- **Зависимости**: + `github.com/oklog/ulid/v2` (чистый Go, CGO не нужен).
|
||||
- **БД**: пересоздание всех шести таблиц (SQLite меняет PK только через
|
||||
rebuild) одной миграцией; первая Go-миграция в проекте — goose до сих пор
|
||||
использовался только с SQL-файлами, нужна регистрация Go-миграций.
|
||||
- **Документация**: новая `docs/conventions/database.md`, правки
|
||||
`docs/conventions/logging.md`, ER-схема `docs/specs/database.md`,
|
||||
ссылка на новую конвенцию из `CLAUDE.md`/README конвенций.
|
||||
- **Не меняется**: семантика дедупа (нашли активную загрузку по любому хешу →
|
||||
та же загрузка), явные `ORDER BY created_at` в списках, инварианты
|
||||
безопасности данных.
|
||||
@@ -0,0 +1,156 @@
|
||||
# identity — идентичность сущностей домена
|
||||
|
||||
Как система идентифицирует сущности: ULID-ключи и их канонический вид,
|
||||
множество инфохэшей загрузки, дедупликация приёма, корреляция в логах.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ULID как первичный ключ сущностей
|
||||
|
||||
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
|
||||
Crockford base32, генерируемый приложением в момент создания записи через
|
||||
единственную точку генерации (`internal/ident`). Сущности: `download`,
|
||||
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
|
||||
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
|
||||
использоваться SHALL NOT. Идентификатор партии раскладки (`apply_batch_id`)
|
||||
SHALL генерироваться тем же способом.
|
||||
|
||||
#### Scenario: Создание загрузки
|
||||
|
||||
- **WHEN** принимается новая загрузка
|
||||
- **THEN** её `id` — валидный ULID в lowercase
|
||||
- **AND** `id` уникален глобально (не совпадает с id других сущностей)
|
||||
|
||||
#### Scenario: Хронологическая сортировка
|
||||
|
||||
- **GIVEN** две загрузки, созданные последовательно
|
||||
- **WHEN** записи сортируются по `id` лексикографически
|
||||
- **THEN** порядок совпадает с порядком создания
|
||||
|
||||
### Requirement: Нормализация и валидация id на входных границах
|
||||
|
||||
Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
|
||||
lowercase до обращения к хранилищу — это касается всех входных границ:
|
||||
URL `/download/{id}`, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
|
||||
несуществующая сущность (404 для страниц), без обращения к БД.
|
||||
|
||||
#### Scenario: Uppercase-вариант id в URL
|
||||
|
||||
- **GIVEN** существующая загрузка с id `01jz…` (lowercase)
|
||||
- **WHEN** клиент открывает `/download/01JZ…` (uppercase)
|
||||
- **THEN** открывается страница той же загрузки
|
||||
|
||||
#### Scenario: Мусор вместо id
|
||||
|
||||
- **WHEN** клиент открывает `/download/abc!!!`
|
||||
- **THEN** ответ — 404, запрос к БД не выполняется
|
||||
|
||||
### Requirement: Множество инфохэшей загрузки
|
||||
|
||||
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
|
||||
`infohash` lowercase hex, `kind` ∈ `v1`|`v2`). При приёме magnet-ссылки
|
||||
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
|
||||
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
|
||||
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
|
||||
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
|
||||
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
|
||||
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
|
||||
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
|
||||
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
|
||||
приём после терминального состояния), но активной из них MUST быть не более
|
||||
одной.
|
||||
|
||||
#### Scenario: Гибридный торрент раскрывает оба хеша
|
||||
|
||||
- **GIVEN** загрузка принята по magnet с v1-хешем
|
||||
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
|
||||
`infohash_v2`
|
||||
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
|
||||
|
||||
#### Scenario: Сопоставление по v2-хешу
|
||||
|
||||
- **GIVEN** загрузка с записями v1- и v2-хешей
|
||||
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
|
||||
- **THEN** раздача сопоставляется с этой загрузкой
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
- **GIVEN** активная загрузка с infohash `h`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
возвращающем загрузку из терминального состояния в активное (ручной retry,
|
||||
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
|
||||
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
|
||||
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
|
||||
сохраняя инвариант «не более одной активной загрузки на infohash».
|
||||
Отказ SHALL происходить до побочных эффектов во внешних системах
|
||||
(повторного добавления торрента в qBittorrent).
|
||||
|
||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||
гибридного торрента): хеш, которым владеет другая активная загрузка,
|
||||
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
|
||||
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
|
||||
бэкстоп вместо удалённого unique-индекса).
|
||||
|
||||
#### Scenario: Retry при занятом хеше
|
||||
|
||||
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
|
||||
#2 с тем же `h`
|
||||
- **WHEN** пользователь вызывает retry для #1
|
||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||
- **AND** активной по `h` остаётся #2
|
||||
|
||||
### Requirement: Корреляция сущностей в логах
|
||||
|
||||
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
|
||||
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
|
||||
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
|
||||
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
|
||||
все записи журнала, относящиеся к сущности, независимо от имени поля.
|
||||
|
||||
#### Scenario: Путь загрузки по логам
|
||||
|
||||
- **GIVEN** загрузка прошла приём, распознавание и раскладку
|
||||
- **WHEN** журнал фильтруется по значению её `id`
|
||||
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)
|
||||
|
||||
### Requirement: Миграция существующих записей
|
||||
|
||||
Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
|
||||
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
|
||||
браться из `created_at` записи, чтобы лексикографический порядок новых id
|
||||
соответствовал историческому порядку создания. Существующий
|
||||
`download.infohash` SHALL быть перенесён в `download_infohash`
|
||||
(нормализация к lowercase, `kind` по длине hex: 40 — `v1`, 64 — `v2`);
|
||||
столбцы `download.infohash` и `download.idempotency_key` SHALL быть удалены.
|
||||
|
||||
#### Scenario: Связи и порядок после миграции
|
||||
|
||||
- **GIVEN** БД с загрузками, распознаваниями и файловыми ссылками на
|
||||
числовых id
|
||||
- **WHEN** миграция выполнена
|
||||
- **THEN** все FK-связи сохранены (распознавания/ссылки указывают на те же
|
||||
загрузки)
|
||||
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
|
||||
- **AND** каждый прежний `infohash` представлен записью в
|
||||
`download_infohash`
|
||||
@@ -0,0 +1,113 @@
|
||||
# state-reconciliation — дельта для ulid-identity
|
||||
|
||||
Механика идемпотентности меняется: снимаемый/восстанавливаемый
|
||||
`idempotency_key` исчезает, инвариант «не более одной активной задачи на
|
||||
infohash» обеспечивается проверкой активности по `download_infohash`
|
||||
(см. capability `identity`).
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Периодическая сверка состояния с реальностью
|
||||
|
||||
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||
выводить состояние задачи из двух независимых признаков: присутствия
|
||||
**источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
|
||||
`download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
|
||||
требование о владении целевым путём: существуют все ссылки последнего батча
|
||||
со статусом раскладки, всё ещё принадлежащие этой загрузке).
|
||||
|
||||
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
|
||||
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
|
||||
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
|
||||
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
|
||||
сверка по матрице трогать SHALL NOT.
|
||||
|
||||
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
|
||||
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
|
||||
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
|
||||
требование о восстановлении зависшей загрузки), не по матрице «источник ×
|
||||
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
|
||||
|
||||
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||
когда выведенное состояние совпадает с текущим).
|
||||
|
||||
#### Scenario: Источник и цель на месте — состояние не меняется
|
||||
|
||||
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
|
||||
разложенные хардлинки существуют
|
||||
- **THEN** задача остаётся в `done`
|
||||
- **AND** запись состояния и лог перехода не выполняются
|
||||
|
||||
#### Scenario: Частичная пропажа цели считается отсутствием
|
||||
|
||||
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||
|
||||
#### Scenario: Задача в deleted сверкой не переоценивается
|
||||
|
||||
- **WHEN** задача находится в `deleted`
|
||||
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
|
||||
бывшему пути появился файл другой загрузки
|
||||
|
||||
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
|
||||
|
||||
- **WHEN** задача в `failed` с `error_code` `qbit_error`
|
||||
- **THEN** сверка её не рассматривает и состояние не меняет
|
||||
|
||||
### Requirement: Восстановление зависшей загрузки при оживлении источника
|
||||
|
||||
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
|
||||
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
|
||||
источник в qBittorrent жив и продвинулся: переход выводится из текущего
|
||||
состояния торрента так же, как при штатной сверке загрузки
|
||||
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
|
||||
`downloading`). Восстановление SHALL опираться на фактическое состояние
|
||||
торрента в qBittorrent, а не на время с момента создания записи.
|
||||
|
||||
После возврата в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
|
||||
на эту задачу: активность задачи выводится только из её `state`, отдельный
|
||||
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
|
||||
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
|
||||
другая активная задача (новый приём, пока эта лежала упавшей), система
|
||||
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
|
||||
сохраняя инвариант «не более одной активной задачи на infohash».
|
||||
|
||||
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
|
||||
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
|
||||
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
|
||||
метаданным в `docs/specs/workflow.md`).
|
||||
|
||||
#### Scenario: Метаданные пришли после magnet_timeout
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
в qBittorrent уже получил метаданные и качается (`downloading`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
- **AND** повторный приём того же infohash снова дедуплицируется на неё
|
||||
|
||||
#### Scenario: Торрент уже завершился, пока задача была в failed
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача переходит в `completed` и продолжает обычный поток
|
||||
(распознавание/раскладка)
|
||||
|
||||
#### Scenario: Источник так и не ожил — состояние не меняется
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача остаётся в `failed`
|
||||
|
||||
#### Scenario: infohash уже занят другой активной задачей
|
||||
|
||||
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
|
||||
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
|
||||
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
|
||||
пытается воскресить #1
|
||||
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
|
||||
- **AND** активной по этому infohash остаётся #2
|
||||
@@ -0,0 +1,81 @@
|
||||
## 1. Фундамент: пакет ident
|
||||
|
||||
- [x] 1.1 Добавить зависимость `github.com/oklog/ulid/v2`; пакет
|
||||
`internal/ident`: `NewID()` (lowercase, monotonic entropy,
|
||||
потокобезопасно), `NewIDAt(t time.Time)` (для миграции/бэкфилла),
|
||||
`Parse(s)` (нормализация регистра + валидация); тесты
|
||||
(lowercase, сортируемость, отказ на мусоре)
|
||||
|
||||
## 2. Схема и миграция
|
||||
|
||||
- [x] 2.1 Механизм Go-миграций goose в `internal/store/migrations`
|
||||
(регистрация через `goose.AddMigrationContext`, совместный прогон с
|
||||
embed SQL-миграциями)
|
||||
- [x] 2.2 Миграция 0006: новые таблицы с TEXT ULID PK (все шесть), перенос
|
||||
данных с маппингом `int → ULID` (timestamp из `created_at`, fallback —
|
||||
время миграции), перенос `download.infohash` → `download_infohash`
|
||||
(lowercase, `kind` по длине hex), удаление `download.infohash` и
|
||||
`download.idempotency_key`, пересоздание индексов
|
||||
- [x] 2.3 Тест миграции на фикстурной БД: FK-связи сохранены, порядок по
|
||||
`id` = порядок по `created_at`, хеши разнесены, `idempotency_key`
|
||||
отсутствует
|
||||
|
||||
## 3. Store
|
||||
|
||||
- [x] 3.1 Типы id `int64 → string` во всех структурах и методах `store`
|
||||
(download, recognition, hint, override, metadata_candidate,
|
||||
file_link, list); генерация ULID через `ident.NewID()` во ВСЕХ
|
||||
Create-методах (вместо `LastInsertId`)
|
||||
- [x] 3.2 Guarded-методы инварианта (design D4):
|
||||
`CreateDownloadIfNoActive` и `ActivateIfNoOtherActive`, каждый — одна
|
||||
write-транзакция; `FindActiveByInfohash`/`ExistsByInfohash` join'ом
|
||||
по любому хешу (для чтения); убрать CASE-восстановление
|
||||
`idempotency_key` из `SetDownloadState`
|
||||
- [x] 3.3 Методы хешей: добавить недостающие хеши загрузке
|
||||
(INSERT OR IGNORE), получать хеши вместе с Download (срез в модели)
|
||||
- [x] 3.4 Поиск в списке (`listWhere`): `EXISTS`-подзапрос по
|
||||
`download_infohash` вместо `LIKE` по удаляемому `download.infohash`
|
||||
|
||||
## 4. Ядро и воркер
|
||||
|
||||
- [x] 4.1 Приём (`ingest`) и discover-adopt — через
|
||||
`CreateDownloadIfNoActive`; хеш из magnet (btih ИЛИ btmh, `kind` по
|
||||
длине hex) пишется в той же транзакции; сигнатуры
|
||||
`Result.DownloadID`, `notifyFailed`, `Notifier.Notify`,
|
||||
`failNotified` — на string
|
||||
- [x] 4.2 Поллинг/сверка: сопоставление раздачи по любому из хешей загрузки
|
||||
(Poll, desync, recovery, preflight); дописывание недостающих v1/v2,
|
||||
когда qBittorrent отдаёт оба; scoped-логгеры — первый известный хеш
|
||||
- [x] 4.3 Retry, recovery-воскрешение и Relink — через
|
||||
`ActivateIfNoOtherActive` (сейчас гонку закрывал unique-индекс —
|
||||
см. design D4); понятная ошибка при занятом хеше
|
||||
- [x] 4.4 `apply_batch_id` генерировать через `ident.NewID()`
|
||||
|
||||
## 5. Внешние границы
|
||||
|
||||
- [x] 5.1 `httpapi`: `ident.Parse` в `pathID` (невалидный → 404 без похода
|
||||
в БД) и для `candidate_id` из формы ревью; сентинелы
|
||||
`downloadID > 0 → != ""`; REST DTO: `id` string, `infohash` →
|
||||
список `infohashes`; карточка показывает все хеши с копированием;
|
||||
live-лукап по любому хешу; проверить шаблоны и ссылки
|
||||
- [x] 5.2 Telegram (`tgbot`): `parseCallback` и callback-data на string-id,
|
||||
`pending map[int64]int64 → map[int64]string`, сентинел `id == 0 →
|
||||
== ""`, ссылки `/review/{id}`; понятный ответ на устаревшую
|
||||
callback-data со старым числовым id
|
||||
|
||||
## 6. Логи и документация
|
||||
|
||||
- [x] 6.1 Атрибуты `<entity>_id` в логах: `recognition_id` у попыток
|
||||
распознавания, `batch_id` у раскладки; сверить с scoped-логгером
|
||||
- [x] 6.2 `docs/conventions/logging.md`: примеры id в формате ULID, grep по
|
||||
голому id как штатная корреляция
|
||||
- [x] 6.3 Новая `docs/conventions/database.md` (TEXT ULID PK, без
|
||||
AUTOINCREMENT, естественные ключи у деталей, lowercase + нормализация);
|
||||
ссылки из `docs/conventions/README.md` и `CLAUDE.md`
|
||||
- [x] 6.4 ER-схема `docs/specs/database.md`: ULID PK, `download_infohash`,
|
||||
удалённые столбцы
|
||||
|
||||
## 7. Проверка
|
||||
|
||||
- [x] 7.1 `task test` и `task lint` зелёные; ручной прогон: приём magnet →
|
||||
дедуп повторного приёма → страница `/download/{id}` с ULID в URL
|
||||
@@ -0,0 +1,161 @@
|
||||
# identity Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Как система идентифицирует сущности домена: ULID-ключи (канонический
|
||||
lowercase-вид, нормализация и валидация на входных границах), множество
|
||||
инфохэшей загрузки (`download_infohash`), инвариант «не более одной
|
||||
активной загрузки на infohash» (дедупликация приёма, атомарный возврат в
|
||||
активное состояние), корреляция сущностей в логах по id.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: ULID как первичный ключ сущностей
|
||||
|
||||
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
|
||||
Crockford base32, генерируемый приложением в момент создания записи через
|
||||
единственную точку генерации (`internal/ident`). Сущности: `download`,
|
||||
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
|
||||
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
|
||||
использоваться SHALL NOT. Идентификатор партии раскладки (`apply_batch_id`)
|
||||
SHALL генерироваться тем же способом.
|
||||
|
||||
#### Scenario: Создание загрузки
|
||||
|
||||
- **WHEN** принимается новая загрузка
|
||||
- **THEN** её `id` — валидный ULID в lowercase
|
||||
- **AND** `id` уникален глобально (не совпадает с id других сущностей)
|
||||
|
||||
#### Scenario: Хронологическая сортировка
|
||||
|
||||
- **GIVEN** две загрузки, созданные последовательно
|
||||
- **WHEN** записи сортируются по `id` лексикографически
|
||||
- **THEN** порядок совпадает с порядком создания
|
||||
|
||||
### Requirement: Нормализация и валидация id на входных границах
|
||||
|
||||
Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
|
||||
lowercase до обращения к хранилищу — это касается всех входных границ:
|
||||
URL `/download/{id}`, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
|
||||
несуществующая сущность (404 для страниц), без обращения к БД.
|
||||
|
||||
#### Scenario: Uppercase-вариант id в URL
|
||||
|
||||
- **GIVEN** существующая загрузка с id `01jz…` (lowercase)
|
||||
- **WHEN** клиент открывает `/download/01JZ…` (uppercase)
|
||||
- **THEN** открывается страница той же загрузки
|
||||
|
||||
#### Scenario: Мусор вместо id
|
||||
|
||||
- **WHEN** клиент открывает `/download/abc!!!`
|
||||
- **THEN** ответ — 404, запрос к БД не выполняется
|
||||
|
||||
### Requirement: Множество инфохэшей загрузки
|
||||
|
||||
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
|
||||
`infohash` lowercase hex, `kind` ∈ `v1`|`v2`). При приёме magnet-ссылки
|
||||
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
|
||||
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
|
||||
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
|
||||
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
|
||||
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
|
||||
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
|
||||
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
|
||||
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
|
||||
приём после терминального состояния), но активной из них MUST быть не более
|
||||
одной.
|
||||
|
||||
#### Scenario: Гибридный торрент раскрывает оба хеша
|
||||
|
||||
- **GIVEN** загрузка принята по magnet с v1-хешем
|
||||
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
|
||||
`infohash_v2`
|
||||
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
|
||||
|
||||
#### Scenario: Сопоставление по v2-хешу
|
||||
|
||||
- **GIVEN** загрузка с записями v1- и v2-хешей
|
||||
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
|
||||
- **THEN** раздача сопоставляется с этой загрузкой
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
- **GIVEN** активная загрузка с infohash `h`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
возвращающем загрузку из терминального состояния в активное (ручной retry,
|
||||
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
|
||||
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
|
||||
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
|
||||
сохраняя инвариант «не более одной активной загрузки на infohash».
|
||||
Отказ SHALL происходить до побочных эффектов во внешних системах
|
||||
(повторного добавления торрента в qBittorrent).
|
||||
|
||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||
гибридного торрента): хеш, которым владеет другая активная загрузка,
|
||||
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
|
||||
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
|
||||
бэкстоп вместо удалённого unique-индекса).
|
||||
|
||||
#### Scenario: Retry при занятом хеше
|
||||
|
||||
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
|
||||
#2 с тем же `h`
|
||||
- **WHEN** пользователь вызывает retry для #1
|
||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||
- **AND** активной по `h` остаётся #2
|
||||
|
||||
### Requirement: Корреляция сущностей в логах
|
||||
|
||||
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
|
||||
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
|
||||
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
|
||||
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
|
||||
все записи журнала, относящиеся к сущности, независимо от имени поля.
|
||||
|
||||
#### Scenario: Путь загрузки по логам
|
||||
|
||||
- **GIVEN** загрузка прошла приём, распознавание и раскладку
|
||||
- **WHEN** журнал фильтруется по значению её `id`
|
||||
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)
|
||||
|
||||
### Requirement: Миграция существующих записей
|
||||
|
||||
Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
|
||||
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
|
||||
браться из `created_at` записи, чтобы лексикографический порядок новых id
|
||||
соответствовал историческому порядку создания. Существующий
|
||||
`download.infohash` SHALL быть перенесён в `download_infohash`
|
||||
(нормализация к lowercase, `kind` по длине hex: 40 — `v1`, 64 — `v2`);
|
||||
столбцы `download.infohash` и `download.idempotency_key` SHALL быть удалены.
|
||||
|
||||
#### Scenario: Связи и порядок после миграции
|
||||
|
||||
- **GIVEN** БД с загрузками, распознаваниями и файловыми ссылками на
|
||||
числовых id
|
||||
- **WHEN** миграция выполнена
|
||||
- **THEN** все FK-связи сохранены (распознавания/ссылки указывают на те же
|
||||
загрузки)
|
||||
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
|
||||
- **AND** каждый прежний `infohash` представлен записью в
|
||||
`download_infohash`
|
||||
@@ -17,10 +17,10 @@ qBittorrent. Capability описывает периодическую и при
|
||||
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||
выводить состояние задачи из двух независимых признаков: присутствия
|
||||
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||
присутствия **цели** (см. требование о владении целевым путём: существуют все
|
||||
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
|
||||
загрузке).
|
||||
**источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
|
||||
`download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
|
||||
требование о владении целевым путём: существуют все ссылки последнего батча
|
||||
со статусом раскладки, всё ещё принадлежащие этой загрузке).
|
||||
|
||||
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
|
||||
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
|
||||
@@ -70,14 +70,14 @@ qBittorrent. Capability описывает периодическую и при
|
||||
`downloading`). Восстановление SHALL опираться на фактическое состояние
|
||||
торрента в qBittorrent, а не на время с момента создания записи.
|
||||
|
||||
При возврате в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) система SHALL восстанавливать идемпотентность задачи
|
||||
(`idempotency_key`), чтобы повторный приём того же infohash снова
|
||||
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем
|
||||
же infohash уже завладела другая активная задача (ключ снимается при падении и
|
||||
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую
|
||||
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более
|
||||
одной активной задачи на infohash».
|
||||
После возврата в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
|
||||
на эту задачу: активность задачи выводится только из её `state`, отдельный
|
||||
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
|
||||
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
|
||||
другая активная задача (новый приём, пока эта лежала упавшей), система
|
||||
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
|
||||
сохраняя инвариант «не более одной активной задачи на infohash».
|
||||
|
||||
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
|
||||
@@ -91,7 +91,7 @@ qBittorrent. Capability описывает периодическую и при
|
||||
в qBittorrent уже получил метаданные и качается (`downloading`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
- **AND** её `idempotency_key` восстанавливается
|
||||
- **AND** повторный приём того же infohash снова дедуплицируется на неё
|
||||
|
||||
#### Scenario: Торрент уже завершился, пока задача была в failed
|
||||
|
||||
|
||||
Reference in New Issue
Block a user