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

5.8 KiB
Raw Blame History

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==1INSERT 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 без изменений. Апгрейд — операция над данными.