Приём: дедуп по 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:
@@ -78,9 +78,14 @@ func (s State) IsTerminal() bool {
|
||||
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
|
||||
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
|
||||
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
|
||||
// - cancelled/deferred — легальная цель из КАЖДОГО не-терминального состояния
|
||||
// (Cancel/Defer проверяют лишь IsTerminal); инвариант закреплён тестом, а не
|
||||
// ручной аккуратностью.
|
||||
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer
|
||||
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной
|
||||
// аккуратностью.
|
||||
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
|
||||
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
|
||||
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
|
||||
// смена статуса, файлы/раздачу не трогает (см. state-reconciliation «Ручное
|
||||
// закрытие загрузки»). deleted строго терминален и цель cancelled не получает.
|
||||
//
|
||||
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
|
||||
// setState отклонит переход (0 строк UPDATE → ошибка).
|
||||
@@ -91,14 +96,14 @@ var allowedTransitions = map[State][]State{
|
||||
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
|
||||
StateReview: {StateLinking, StateRecognizing, StateCancelled, StateDeferred, StateOrphaned, StateDeleted},
|
||||
StateLinking: {StateDone, StateReview, StateFailed, StateCancelled, StateDeferred},
|
||||
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted},
|
||||
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted, StateCancelled},
|
||||
StateDeferred: {StateLinking, StateRecognizing, StateCancelled, StateOrphaned, StateDeleted},
|
||||
StateStuck: {StateDownloading, StateCompleted, StateCancelled, StateDeferred},
|
||||
StateFailed: {StateDownloading, StateCompleted},
|
||||
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted},
|
||||
StateFailed: {StateDownloading, StateCompleted, StateCancelled},
|
||||
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted, StateCancelled},
|
||||
StateCancelled: {StateRecognizing, StateOrphaned, StateDeleted},
|
||||
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted},
|
||||
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted},
|
||||
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted, StateCancelled},
|
||||
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted, StateCancelled},
|
||||
StateDeleted: nil, // окончательно терминально: сверка его не переоценивает
|
||||
}
|
||||
|
||||
@@ -616,6 +621,68 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Do
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// reingestHoldingStates — desync-состояния, которые удерживают источник ради
|
||||
// незакрытого намерения и потому БЛОКИРУЮТ повторный приём наравне с активными:
|
||||
// target_missing (источник жив, ждёт relink) и orphaned (источник пропал, запись
|
||||
// держит претензию на последнюю копию). Прочие терминальные (done/cancelled/
|
||||
// failed/reverted/deleted) повторный приём НЕ блокируют — это осознанная свежая
|
||||
// попытка. «Блокирующие» = активные (не-терминальные) ∪ reingestHoldingStates.
|
||||
var reingestHoldingStates = []State{StateTargetMissing, StateOrphaned}
|
||||
|
||||
// FindReingestBlockingByInfohash возвращает задачу, блокирующую повторный приём
|
||||
// любого из hashes: активную (строго не-терминальную) ЛИБО удерживающую источник
|
||||
// desync-запись (target_missing/orphaned), приоритет — активной. Либо (nil, nil).
|
||||
// Читающая основа расширенного дедупа приёма (пред-рид ДО создания); инвариант
|
||||
// «≤1 активной на infohash» держит отдельный active-гард CreateDownloadIfNoActive,
|
||||
// в который desync-состояния НЕ заводятся.
|
||||
func (s *Store) FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
|
||||
norm := normalizeHashes(hashes)
|
||||
// Активная имеет приоритет: если по хешу есть и активная, и desync-запись
|
||||
// (инвариант это допускает), присоединяемся к активной.
|
||||
d, err := findActiveByInfohash(ctx, s.DB, norm, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d == nil {
|
||||
d, err = findByInfohashInStates(ctx, s.DB, norm, reingestHoldingStates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if d != nil {
|
||||
if err := attachInfohashesOne(ctx, s.DB, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// findByInfohashInStates — выборка «задача по любому из хешей в одном из states»
|
||||
// (позитивный фильтр `state IN (...)`, в отличие от findActiveByInfohash с
|
||||
// `NOT IN terminalStates`). hashes уже нормализованы; хеши найденной загрузки НЕ
|
||||
// подгружаются. Пустые hashes/states → (nil, nil).
|
||||
func findByInfohashInStates(ctx context.Context, q sqlx.QueryerContext, hashes []string, states []State) (*Download, error) {
|
||||
if len(hashes) == 0 || len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var args []any
|
||||
hashPh := placeholders(&args, hashes)
|
||||
statePh := placeholders(&args, states)
|
||||
query := `SELECT download.* FROM download
|
||||
JOIN download_infohash dh ON dh.download_id = download.id
|
||||
WHERE dh.infohash IN (` + hashPh + `) AND download.state IN (` + statePh + `)
|
||||
ORDER BY download.id DESC LIMIT 1`
|
||||
var d Download
|
||||
err := sqlx.GetContext(ctx, q, &d, query, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find by infohash in states: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
|
||||
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
|
||||
// excludeID исключает саму проверяемую задачу (она может быть активной,
|
||||
|
||||
Reference in New Issue
Block a user