Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»

Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.

- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
  активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
  привязывается к существующей записи (спящей, без обращения к qBittorrent),
  а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
  deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
  read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
  общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
  кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
  файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
  в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
  кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
  привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
  дедупе ведёт на страницу существующей записи.

Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 20:15:37 +03:00
co-authored by Claude Opus 4.8
parent b8657120fe
commit 1369a9cabe
26 changed files with 1157 additions and 68 deletions
+31
View File
@@ -897,6 +897,37 @@ func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
return nil
}
// Dismiss — универсальный стоп-кран: переводит задачу в терминальный cancelled из
// ЛЮБОГО состояния, кроме deleted, ТОЛЬКО меняя статус. В отличие от Delete не
// трогает ни файлы (библиотечные хардлинки done/orphaned остаются на месте), ни
// раздачу в qBittorrent, ни цель; source-preflight не делает. Служит закрытием
// зависшей/спорной/лишней записи (в т.ч. дубля-близнеца в target_missing). Из
// cancelled — идемпотентный no-op БЕЗ setState: иначе перезаписал бы error_code,
// подменив причину прежнего Cancel/Dismiss. Помечает переход user_dismiss
// (отличает от reconcile и от штатного Cancel с пустым кодом).
func (w *Worker) Dismiss(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "dismiss", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return fmt.Errorf("dismiss: %w", err)
}
if d.State == store.StateDeleted {
return fmt.Errorf("dismiss: download %s is deleted (strictly terminal): %w", id, ErrConflict)
}
if d.State == store.StateCancelled {
return nil // уже закрыта — no-op, error_code прежней отмены не трогаем
}
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "user_dismiss", "закрыто пользователем"); err != nil {
return fmt.Errorf("dismiss: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
"from", d.State, "to", store.StateCancelled, "code", "user_dismiss")
return nil
}
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
// qBittorrent и возвращает в downloading.
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
+52
View File
@@ -446,6 +446,58 @@ func TestCancel(t *testing.T) {
}
}
// Dismiss — стоп-кран: переводит в cancelled только сменой статуса, не трогая
// раздачу и файлы, из любого состояния, кроме deleted; из cancelled — no-op без
// перезаписи error_code.
func TestDismiss(t *testing.T) {
t.Run("from-done-keeps-source-and-files", func(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateDone
qb := &fakeQbt{}
w := newTestWorker(st, qb)
if err := w.Dismiss(context.Background(), "1"); err != nil {
t.Fatalf("Dismiss: %v", err)
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
if got := st.downloads["1"].ErrorCode.String; got != "user_dismiss" {
t.Errorf("error_code = %q, want user_dismiss", got)
}
if len(qb.deleted) != 0 {
t.Errorf("раздачу трогать не должны, Delete вызван %d раз", len(qb.deleted))
}
})
t.Run("from-deleted-rejected", func(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateDeleted
w := newTestWorker(st, &fakeQbt{})
if err := w.Dismiss(context.Background(), "1"); err == nil {
t.Error("ожидалась ошибка Dismiss из deleted")
}
if st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted (не изменилось)", st.downloads["1"].State)
}
})
t.Run("from-cancelled-noop-keeps-code", func(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateCancelled
st.downloads["1"].ErrorCode = store.NullString("prior_reason")
w := newTestWorker(st, &fakeQbt{})
if err := w.Dismiss(context.Background(), "1"); err != nil {
t.Fatalf("Dismiss no-op: %v", err)
}
if got := st.downloads["1"].ErrorCode.String; got != "prior_reason" {
t.Errorf("error_code = %q, no-op не должен его переписывать", got)
}
if len(st.transitions) != 0 {
t.Errorf("no-op не должен писать переход, got %d", len(st.transitions))
}
})
}
func TestRetry(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateStuck