Files
jellybit/openspec/changes/ingest-dedup-integrity/design.md
T
avandClaude Opus 4.8 4475fbd548 Приём: гард дедуп-дозаписи хешей (F1) и апгрейд catched-magnet до torrent (F6)
Два дефекта дедуп-веток приёма (ревью Fable 2026-07-08), оба про инвариант
«≤1 активная загрузка на infohash» и сохранность источника.

F1: дедуп-ветка CreateDownloadIfNoActive дописывала все хеши входящего
источника в найденную активную задачу без пер-хеш гарда владения (в отличие
от AddInfohashes). Гибрид {v1,v2}, дедупнувшись на задачу B (владелец v2),
крал v1 у активной A → две активные владели v1. Теперь дозапись под тем же
гардом: хеш, которым владеет другая активная задача, не дописывается.

F6: при дедупе .torrent-байт на пойманную magnet-задачу (catched) байты
выбрасывались, source_type оставался magnet → worker добавлял по magnet-URL →
вечный metaDL → failed (magnet закрытого трекера без DHT метаданные не
докачает). Новый guarded-метод UpgradeCatchedMagnetToTorrent атомарно
сохраняет байты и меняет source_type magnet→torrent, но только пока задача в
catched (worker источник ещё не отдал). Ingest зовёт апгрейд на обоих
дедуп-путях. Это целевое исключение из правила спеки «при дедупе байты не
сохраняем» — оформлено MODIFIED-дельтой ingest.

Схема БД не меняется (download_torrent и source_type уже есть).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00

80 lines
5.8 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
Приём дедуплицирует входящий источник по активной задаче двумя путями:
1. **Быстрый чек**`Ingest` вызывает `FindActiveByInfohash`, при попадании
уходит в `attached()` (дозапись недостающих хешей через `AddInfohashes`).
Это основной путь.
2. **Гонка** — быстрый чек пуст, но пока выводили имя/готовили запись,
активная задача появилась; `CreateDownloadIfNoActive` внутри своей
транзакции находит её и возвращает как дедуп (дозапись хешей внутри той же
tx).
F1 живёт в пути 2 (неохраняемая дозапись). F6 задевает ОБА пути: в реальном
сценарии первым отрабатывает быстрый чек (путь 1), поэтому апгрейд обязан
работать и там.
## F1 — пер-хеш гард дозаписи в дедуп-ветке
**Решение.** В дедуп-ветке `CreateDownloadIfNoActive` заменяем безусловный
цикл `INSERT OR IGNORE` на тот же гард, что в `AddInfohashes`: для каждого
хеша `h` проверяем `findActiveByInfohash(ctx, tx, {h}, existing.ID)`; если
другая активная задача владеет `h` — пропускаем (не дописываем), иначе
`INSERT OR IGNORE`. Транзакция уже открыта, `excludeID = existing.ID`
исключает саму дедуп-цель (её собственные хеши не конфликтуют с ней самой).
**Почему пропуск, а не ошибка.** Дедуп по контракту не падает — возвращает
существующую задачу. Конфликтный хеш принадлежит другой активной задаче;
молча его не трогаем — это и есть сохранение инварианта. В отличие от
`AddInfohashes`, который сигналит `ErrInfohashTaken` вызывающему (там это
осмысленно), у дедуп-ветки наблюдаемого канала ошибки нет и он не нужен:
поведение — «присоединиться к найденной задаче, чужое не красть».
## F6 — апгрейд catched-magnet до torrent
**Решение.** Новый guarded-метод хранилища:
```
UpgradeCatchedMagnetToTorrent(ctx, downloadID string, torrentBlob []byte) (bool, error)
```
в одной write-транзакции:
1. Пустой `torrentBlob``(false, nil)` (защитный no-op).
2. Гардированный UPDATE:
`UPDATE download SET source_type='torrent', updated_at=?
WHERE id=? AND source_type='magnet' AND state='catched'`.
`RowsAffected==0` → задача не подходит (уже `downloading`/отменена/не
magnet) → коммит без эффекта, `(false, nil)`.
3. `RowsAffected==1` → `INSERT OR REPLACE INTO download_torrent(download_id,
data)` (у magnet блоба нет; `OR REPLACE` — страховка идемпотентности),
коммит, `(true, nil)`.
**Почему гард `state='catched'`.** Апгрейд имеет смысл только пока worker ещё
не отдал источник в qBittorrent. В `catched` worker на шаге добавления
выбирает способ по `source_type` (`internal/worker/worker.go:sourceAddParts`):
после смены на `torrent` он добавит файлом — метаданные приедут сразу. Если
задача уже `downloading`, magnet давно в qBittorrent (застрял в metaDL), и
смена `source_type` его не переотдаст; это отдельная забота retry/desync, не
приёма. Тот же паттерн ре-валидации, что у `PromoteCatched`: если задачу
успели отменить, UPDATE не заденет строк и апгрейд просто не применится.
**Где вызываем.** Оба дедуп-пути `Ingest` сводим к `attached()` (в ветке
гонки `existing` от `CreateDownloadIfNoActive` уже с подгруженными хешами, так
что переиспользование безопасно). В `attached()` после дозаписи хешей: если
входящий источник — `torrent` и есть байты, зовём
`UpgradeCatchedMagnetToTorrent`. Вызов best-effort: ошибка/неуспех логируются
`Warn`/`Info`, приём не валится (как и дозапись хешей). Результат приёма
по-прежнему несёт `State` существующей задачи (остаётся `catched`) — меняется
лишь способ будущего добавления.
**Идемпотентность и повтор.** Повторный `.torrent` того же хеша: первый
апгрейд перевёл задачу в `torrent`, гард `source_type='magnet'` на втором даст
`RowsAffected==0` → no-op. Байты уже сохранены при создании torrent-ветки —
`OR REPLACE` перезапишет теми же данными без вреда.
## Границы
Схему не трогаем: `download_torrent` и колонка `source_type` уже есть. ER-схема
`docs/specs/database.md` без изменений. Апгрейд — операция над данными.