Обработка рассинхрона состояния с реальностью (state-reconciliation)

Распознаём ручное удаление источника (раздача в qBittorrent) и/или цели
(разложенные хардлинки) и отражаем его в состоянии задачи, без автодействий.

- Новая capability state-reconciliation (OpenSpec): фоновая сверка по матрице
  «источник × цель» → состояния target_missing/orphaned/deleted, переходы и
  самовосстановление (healing).
- worker: reconcileDesync в Poll (только разложенные/desync-задачи), дебаунс
  пропажи источника (порог [worker].source_missing_threshold) и синхронный
  preflight перед действиями (relink/recognize/apply/undo) — не доверяем
  state в БД.
- layout.Undo: отказ снять последнюю копию (nlink<=1 или нет источника),
  отказ всего батча без частичного отката (ErrLastCopy).
- store: единый список terminalStates для IsTerminal и FindActiveByInfohash
  (иначе семантика «активности» разъезжается), столбец source_miss_count,
  миграция 0003.
- httpapi/web и Telegram: показ новых состояний и уведомления о рассинхроне.
- Доки: workflow.md, jellyfin-layout.md, database.md (+0003), config.

Change заархивирован в openspec/changes/archive, дельта влита в openspec/specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
av
2026-06-29 11:07:09 +03:00
co-authored by Claude Opus 4.8
parent f9d7fd9216
commit cc7e51b3a4
29 changed files with 1556 additions and 80 deletions
+46 -12
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"slices"
"strings"
"time"
)
@@ -26,19 +27,34 @@ const (
StateFailed State = "failed"
StateCancelled State = "cancelled"
StateReverted State = "reverted" // Ф3
// Состояния рассинхрона с реальностью (см. state-reconciliation).
StateTargetMissing State = "target_missing" // источник есть, цель удалена → relink
StateOrphaned State = "orphaned" // источник пропал, цель (последняя копия) есть
StateDeleted State = "deleted" // нет ни источника, ни цели
)
// terminalStates — единый список окончательно остановленных состояний:
// источник истины и для IsTerminal, и для выборки «активных» задач
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
// снимается по IsTerminal, а активность считалась бы по другому списку).
//
// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по
// тем же причинам, что reverted/cancelled: дальше двигает либо человек
// (relink из target_missing), либо фоновая сверка (healing/прогрессия),
// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен.
var terminalStates = []State{
StateDone, StateCancelled, StateFailed, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
// состояний снимается ключ идемпотентности — тот же infohash можно завести
// заново новой задачей (см. architecture.md, «повторное добавление»).
// stuck терминальным не считается: задача восстановима (retry).
func (s State) IsTerminal() bool {
switch s {
case StateDone, StateCancelled, StateFailed, StateReverted:
return true
default:
return false
}
return slices.Contains(terminalStates, s)
}
// SourceType — вид источника загрузки.
@@ -61,8 +77,11 @@ type Download struct {
State State `db:"state"`
ErrorCode sql.NullString `db:"error_code"`
ErrorMsg sql.NullString `db:"error_msg"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
SourceMissCount int `db:"source_miss_count"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
@@ -141,11 +160,12 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
// (nil, nil), если её нет. Основа идемпотентного приёма.
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
term := []State{StateDone, StateCancelled, StateFailed, StateReverted}
ph := make([]string, len(term))
args := make([]any, 0, len(term)+1)
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
ph := make([]string, len(terminalStates))
args := make([]any, 0, len(terminalStates)+1)
args = append(args, infohash)
for i, st := range term {
for i, st := range terminalStates {
ph[i] = "?"
args = append(args, string(st))
}
@@ -205,6 +225,20 @@ WHERE id = ?`
return nil
}
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
// Состояние не трогает — это отдельная от перехода фоновая отметка.
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, n int) error {
res, err := s.DB.ExecContext(ctx,
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
if err != nil {
return fmt.Errorf("set download %d source_miss_count: %w", id, err)
}
if affected, _ := res.RowsAffected(); affected == 0 {
return fmt.Errorf("set download %d source_miss_count: not found", id)
}
return nil
}
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
func nullArg(s string) any {
if s == "" {