Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master): - ingest-dedup-integrity (F1, F6) → спека ingest - retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation - linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout и state-reconciliation Дельты влиты в openspec/specs, changes перенесены в openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5.8 KiB
Context
Приём дедуплицирует входящий источник по активной задаче двумя путями:
- Быстрый чек —
IngestвызываетFindActiveByInfohash, при попадании уходит вattached()(дозапись недостающих хешей черезAddInfohashes). Это основной путь. - Гонка — быстрый чек пуст, но пока выводили имя/готовили запись,
активная задача появилась;
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-транзакции:
- Пустой
torrentBlob→(false, nil)(защитный no-op). - Гардированный UPDATE:
UPDATE download SET source_type='torrent', updated_at=? WHERE id=? AND source_type='magnet' AND state='catched'.RowsAffected==0→ задача не подходит (ужеdownloading/отменена/не magnet) → коммит без эффекта,(false, nil). 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 без изменений. Апгрейд — операция над данными.