Два дефекта дедуп-веток приёма (ревью 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>
80 lines
5.8 KiB
Markdown
80 lines
5.8 KiB
Markdown
## 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` без изменений. Апгрейд — операция над данными.
|