Обработка рассинхрона состояния с реальностью (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:
+46
-12
@@ -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 == "" {
|
||||
|
||||
@@ -72,6 +72,31 @@ func TestFindActiveByInfohash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
||||
// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что
|
||||
// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и
|
||||
// IsTerminal обязаны опираться на один список терминальных состояний.
|
||||
func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
for _, st := range []State{StateTargetMissing, StateOrphaned, StateDeleted} {
|
||||
t.Run(string(st), func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
ih := "33333333333333333333333333333333333333" + string(st[0:2])
|
||||
|
||||
id, err := store.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
||||
t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
|
||||
// тот же infohash заново (повторная закачка спустя время).
|
||||
func TestTerminalReleasesInfohash(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
-- Счётчик подряд идущих тиков фоновой сверки без раздачи в qBittorrent.
|
||||
-- Дебаунс пропажи источника: помечаем orphaned/deleted только при достижении
|
||||
-- порога [worker].source_missing_threshold (см. state-reconciliation).
|
||||
ALTER TABLE download ADD COLUMN source_miss_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE download DROP COLUMN source_miss_count;
|
||||
Reference in New Issue
Block a user