Обработка рассинхрона состояния с реальностью (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
+1
View File
@@ -126,6 +126,7 @@ func runServe(args []string) error {
PollInterval: cfg.Worker.PollInterval.Std(),
StuckAfter: cfg.Worker.StuckAfter.Std(),
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
SourceMissingThreshold: cfg.Worker.SourceMissingThreshold,
}, logger)
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
+1
View File
@@ -62,6 +62,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
[recognition]
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
+1
View File
@@ -41,6 +41,7 @@
[worker]
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
source_missing_threshold = 3 # тиков сверки без раздачи, чтобы счесть источник удалённым
[recognition]
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
+3 -1
View File
@@ -9,7 +9,8 @@
> в том же change. Расхождение схемы с миграциями считаем багом
> документации.
>
> Состояние на: миграции `0001_init`, `0002_recognition_plan`.
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
> `0003_source_miss_count`.
Назначение таблиц и почему так — [architecture.md](architecture.md) →
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
@@ -34,6 +35,7 @@ erDiagram
TEXT state "NOT NULL; см. workflow.md"
TEXT error_code "nullable"
TEXT error_msg "nullable"
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
}
+12
View File
@@ -57,6 +57,18 @@ inode общий — диск не дублируется.
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
предупреждением в лог — см. architecture.md → «Раскладка файлов».
## Безопасный undo (не снимать последнюю копию)
`Undo` снимает **лишний** хардлинк, а не единственный файл. Перед удалением
батча `layout` проверяет каждую цель: если исходный файл уже не существует
**или** у цели не осталось других жёстких ссылок (`nlink <= 1`), это —
последняя копия данных, и весь `Undo` отклоняется целиком (ошибка
`ErrLastCopy`), не сняв ни одной ссылки (частичный откат тоже стёр бы часть
данных). Так нарушенный инвариант «источник неприкосновенен» (источник
удалён вручную) не приводит к потере данных. Отсутствующую цель `Undo`
пропускает как уже снятую (идемпотентность). Связь с состояниями
рассинхрона — [workflow.md](workflow.md) → «Сверка с реальностью».
## Крайние случаи
- **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin
+34
View File
@@ -39,9 +39,19 @@ stateDiagram-v2
stuck --> downloading: Retry
failed --> downloading: Retry
done --> target_missing: сверка — цель удалена
done --> orphaned: сверка — источник пропал
target_missing --> recognizing: Привязать заново
target_missing --> orphaned: источник тоже пропал
target_missing --> deleted: источник тоже пропал
orphaned --> deleted: цель тоже удалена
target_missing --> done: healing (цель вернулась)
orphaned --> done: healing (источник вернулся)
done --> [*]
cancelled --> [*]
reverted --> [*]
deleted --> [*]
note right of cancelled
«Отклонить» доступно из любого
@@ -85,6 +95,30 @@ stateDiagram-v2
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
qBittorrent.
## Сверка с реальностью (рассинхрон)
Состояние в БД может разойтись с диском при **ручном** удалении: раздачу
стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые
хардлинки). `worker` периодически сверяет уже разложенные задачи с фактом по
двумерной матрице «источник × цель» (источник = раздача в qBittorrent,
цель = разложенные хардлинки на ФС) и выводит состояние:
- **target_missing** — источник на месте, цель удалена. Доступна команда
«Привязать заново» (`→ recognizing`); авто-действий нет.
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
- **deleted** — нет ни источника, ни цели; терминально.
Сверка трогает только `done`/`target_missing`/`orphaned`/`deleted`
активные и пользовательски-терминальные (`reverted`/`cancelled`/`failed`/
`stuck`) состояния не задевает. Реальность «лечится» сама: при возврате
источника/цели задача переходит обратно (вплоть до `done`). Пропажа
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
которым нужен источник (relink/распознать/применить/undo), проверяют его
**синхронно перед действием** и не полагаются на фоновую сверку. Полные
требования — `openspec/specs/state-reconciliation/`.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
+15 -28
View File
@@ -25,38 +25,25 @@
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
сериала с провайдер-id).
### Рассинхрон состояния с реальностью (удалённый торрент / файлы)
### Удаление средствами jellybit («единое окно», path 2)
Состояние jellybit может разойтись с тем, что реально лежит на диске.
Несколько сценариев разной остроты:
Распознавание **ручного** удаления (источник из qBittorrent / цель из
Jellyfin) и пометка рассинхрона уже сделаны: фоновая сверка по матрице
«источник × цель» → состояния `target_missing`/`orphaned`/`deleted`,
безопасный `undo` (не снимает последнюю копию, `nlink <= 1`), синхронный
preflight перед действиями. См. `openspec/specs/state-reconciliation/`,
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
- **Жёсткий — удалён источник.** Раздачу удаляют (вручную или авто по
достижении seed limit), и qBittorrent стирает скачанные файлы. Тогда
хардлинк в библиотеке становится **последней** ссылкой на inode, и
обычный `undo` (`unlink` цели + чистка пустых каталогов) сотрёт
единственную копию насовсем — прямая потеря данных. Инвариант «источник
неприкосновенен» молчаливо перестаёт держаться: источника уже нет.
- **Мягкий — удалена цель.** Файлы убрали из библиотеки Jellyfin (вручную
или из самого Jellyfin), а jellybit по-прежнему числит загрузку в
`done`. Состояние врёт: ссылок уже нет, а сервис думает, что всё
разложено.
Нужно продумать сверку записанного состояния (`file_link`, состояние
загрузки) с фактом на ФС:
- как `worker` реагирует на исчезновение раздачи из qBittorrent
(состояние/пометка загрузки);
- как `undo` защищается, когда источник недоступен — например,
отказываться удалять, если у целевого файла счётчик ссылок == 1 (нет
второй копии) или исходный путь не существует, и явно об этом сообщать.
Откат снимает **лишний** хардлинк, а не последнюю копию файла;
- как ловить пропажу целевых файлов и отражать её в состоянии (напр.
периодическая сверка или проверка при показе — «разложено, но файлов
нет»), чтобы можно было осознанно перепривязать/переразложить.
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Нужно
продумать: команду удаления (снять наши хардлинки + опц. удалить раздачу из
qBittorrent с файлами), подтверждение осознанности (а не случайный клик) и
как это сочетается с инвариантом «источник неприкосновенен», когда
пользователь сам просит убрать источник.
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo,
инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`).
[architecture.md](specs/architecture.md) → «Раскладка файлов»,
[workflow.md](specs/workflow.md).
### Наблюдаемость: метрики и учёт стоимости LLM
+8
View File
@@ -99,6 +99,10 @@ type Worker struct {
PollInterval Duration `toml:"poll_interval"`
StuckAfter Duration `toml:"stuck_after"`
MagnetTimeout Duration `toml:"magnet_timeout"`
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
SourceMissingThreshold int `toml:"source_missing_threshold"`
}
// Recognition — пороги распознавания.
@@ -176,6 +180,7 @@ func Default() *Config {
PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour),
MagnetTimeout: Duration(30 * time.Minute),
SourceMissingThreshold: 3,
},
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
HTTP: HTTP{Listen: ":8080"},
@@ -241,6 +246,9 @@ func (c *Config) validate() error {
if c.LLM.MaxRetries < 0 {
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
}
if c.Worker.SourceMissingThreshold < 1 {
errs = append(errs, fmt.Errorf("worker.source_missing_threshold %d must be >= 1", c.Worker.SourceMissingThreshold))
}
// Обязательные секреты включённых секций (ловит криво отрендеренный деплоем
// файл). qBittorrent — ядро, пароль нужен всегда.
+19 -2
View File
@@ -132,7 +132,8 @@ type downloadView struct {
Terminal bool
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled — можно перепривязать заново
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -317,7 +318,23 @@ func toView(d store.Download) downloadView {
Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing,
Note: desyncNote(d.State),
}
}
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
func desyncNote(s store.State) string {
switch s {
case store.StateTargetMissing:
return "разложено, но файлов в библиотеке нет — можно привязать заново"
case store.StateOrphaned:
return "источник удалён — это последняя копия данных, откат недоступен"
case store.StateDeleted:
return "удалены и источник, и файлы в библиотеке"
default:
return ""
}
}
+66 -6
View File
@@ -228,6 +228,11 @@ type Result struct {
// ErrCollision — цель существует и это другой файл (нужен review).
var ErrCollision = errors.New("layout: target collision")
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
var ErrLastCopy = errors.New("layout: refusing to remove last remaining copy")
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
@@ -367,16 +372,41 @@ func sameFile(src, dst string) (bool, error) {
// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
//
// Защита от потери данных: сперва предпроверка всего батча — если хоть одна
// существующая цель оказывается последней копией (источник пропал или
// nlink<=1), весь Undo отклоняется с ErrLastCopy и НИ ОДНА ссылка не
// снимается (иначе частичный откат стёр бы часть данных). Так откат снимает
// лишний хардлинк, а не единственный файл.
func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
log := logctx.FromOr(ctx, l.log)
// Предпроверка: путь под библиотекой + не последняя копия.
for _, ln := range links {
if _, err := undoRoot(l, ln.Dst); err != nil {
return 0, err
}
fi, err := os.Lstat(ln.Dst)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // цели уже нет — снимать нечего (идемпотентно)
}
return 0, fmt.Errorf("layout: undo stat %q: %w", ln.Dst, err)
}
last, err := isLastCopy(fi, ln.Src)
if err != nil {
return 0, fmt.Errorf("layout: undo check %q: %w", ln.Dst, err)
}
if last {
return 0, fmt.Errorf("%w: %q (источник %q недоступен)", ErrLastCopy, ln.Dst, ln.Src)
}
}
removed := 0
for _, ln := range links {
root := l.movies
if !underRoot(l.movies, ln.Dst) {
root = l.series
}
if !underRoot(root, ln.Dst) {
return removed, fmt.Errorf("layout: undo outside library: %q", ln.Dst)
root, err := undoRoot(l, ln.Dst)
if err != nil {
return removed, err
}
if err := os.Remove(ln.Dst); err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -392,6 +422,36 @@ func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
return removed, nil
}
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
// если путь не под movies/series (откат трогает только библиотеку).
func undoRoot(l *Layouter, dst string) (string, error) {
root := l.movies
if !underRoot(l.movies, dst) {
root = l.series
}
if !underRoot(root, dst) {
return "", fmt.Errorf("layout: undo outside library: %q", dst)
}
return root, nil
}
// isLastCopy сообщает, является ли цель последней копией данных: исходный файл
// уже не существует, либо у цели не осталось других жёстких ссылок (nlink<=1).
// В обоих случаях unlink цели уничтожил бы единственную копию.
func isLastCopy(fi os.FileInfo, src string) (bool, error) {
if _, err := os.Lstat(src); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return true, nil // источника нет — цель единственная копия
}
return false, fmt.Errorf("stat source %q: %w", src, err)
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("unexpected stat type for %q", fi.Name())
}
return st.Nlink <= 1, nil
}
// pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root.
// Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно.
func pruneEmptyDirs(dir, root string) {
+54
View File
@@ -232,6 +232,60 @@ func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) {
}
}
func TestUndo_RefusesLastCopyWhenSourceGone(t *testing.T) {
f := newFixture(t)
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "data"), Role: RoleMain}}})
if _, err := f.l.Apply(context.Background(), links); err != nil {
t.Fatal(err)
}
// Источник удалён (как при удалении раздачи из qBittorrent) → цель стала
// последней копией (nlink упал до 1).
if err := os.Remove(links[0].Src); err != nil {
t.Fatal(err)
}
n, err := f.l.Undo(context.Background(), links)
if !errors.Is(err, ErrLastCopy) {
t.Fatalf("err = %v, want ErrLastCopy", err)
}
if n != 0 {
t.Errorf("removed = %d, want 0 (ничего не сняли)", n)
}
// Цель НЕ удалена — данные целы.
if _, err := os.Stat(links[0].Dst); err != nil {
t.Errorf("target must remain (last copy): %v", err)
}
}
func TestUndo_RefusesWholeBatchIfAnyLastCopy(t *testing.T) {
f := newFixture(t)
links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021,
Files: []PlanFile{
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
{Src: f.srcFile(t, "s/e2.mkv", "2"), Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
}})
if _, err := f.l.Apply(context.Background(), links); err != nil {
t.Fatal(err)
}
// Источник второй серии пропал — весь батч должен быть отклонён целиком.
if err := os.Remove(links[1].Src); err != nil {
t.Fatal(err)
}
n, err := f.l.Undo(context.Background(), links)
if !errors.Is(err, ErrLastCopy) {
t.Fatalf("err = %v, want ErrLastCopy", err)
}
if n != 0 {
t.Errorf("removed = %d, want 0 (батч не трогаем)", n)
}
// Первая ссылка (источник жив) НЕ снята — частичного отката нет.
if _, err := os.Stat(links[0].Dst); err != nil {
t.Errorf("first target must remain (no partial undo): %v", err)
}
}
func TestUndo_Idempotent(t *testing.T) {
f := newFixture(t)
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
+44 -10
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,6 +77,9 @@ type Download struct {
State State `db:"state"`
ErrorCode sql.NullString `db:"error_code"`
ErrorMsg sql.NullString `db:"error_msg"`
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
SourceMissCount int `db:"source_miss_count"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
@@ -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 == "" {
+25
View File
@@ -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;
+2
View File
@@ -246,6 +246,8 @@ func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyE
switch event {
case worker.EventDone:
text = b.renderDone(rd)
case worker.EventTargetMissing, worker.EventOrphaned:
text, kb = b.renderDesync(rd, event), b.webOnly(downloadID)
default:
text, kb = b.renderCard(rd)
}
+16
View File
@@ -95,6 +95,22 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n)
}
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
title := rd.Plan.Title
if title == "" {
title = "#" + itoa(rd.Download.ID)
}
switch event {
case worker.EventTargetMissing:
return fmt.Sprintf("⚠️ «%s»: файлы удалены из библиотеки, источник на месте — можно привязать заново.", title)
case worker.EventOrphaned:
return fmt.Sprintf("⚠️ «%s»: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", title)
default:
return fmt.Sprintf("⚠️ «%s»: рассинхрон состояния.", title)
}
}
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {
+196
View File
@@ -0,0 +1,196 @@
package worker
import (
"context"
"errors"
"fmt"
"os"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// desyncStates — состояния, которые ведёт сверка с реальностью: уже
// разложенные (done) и сами состояния рассинхрона. Активные и
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
// трогает (см. state-reconciliation).
var desyncStates = []store.State{
store.StateDone,
store.StateTargetMissing,
store.StateOrphaned,
store.StateDeleted,
}
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
// (см. state-reconciliation, D1).
func deriveState(sourcePresent, targetPresent bool) store.State {
switch {
case sourcePresent && targetPresent:
return store.StateDone
case sourcePresent && !targetPresent:
return store.StateTargetMissing
case !sourcePresent && targetPresent:
return store.StateOrphaned
default:
return store.StateDeleted
}
}
// reconcileDesync сверяет разложенные/desync-задачи с реальностью. Вызывается
// из Poll под w.mu. byHash — карта присутствующих в qBittorrent раздач.
func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torrent) {
cands, err := w.store.ListDownloadsByState(ctx, desyncStates...)
if err != nil {
w.log.Warn("reconcile list desync failed", "capability", capIngest, "error", err)
return
}
for _, d := range cands {
w.reconcileOneDesync(ctx, d, byHash)
}
}
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
// дебаунсом) и цели, выводит состояние и переходит при изменении.
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
if !d.Infohash.Valid {
return // нечем сопоставить источник
}
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
// идущих промахов; любое появление сбрасывает счётчик.
sourcePresent := w.debounceSource(ctx, d, sourceSeen)
targetPresent, err := w.targetPresent(ctx, d.ID)
if err != nil {
logctx.From(ctx).Warn("reconcile target probe failed", "error", err)
return // не можем проверить цель — не дёргаем состояние
}
want := deriveState(sourcePresent, targetPresent)
if want == d.State {
return
}
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
}
// debounceSource обновляет счётчик промахов источника и возвращает, считать ли
// источник присутствующим с учётом порога. Запись счётчика — только при его
// изменении (без лишних UPDATE на каждом тике).
func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSeen bool) bool {
threshold := max(w.cfg.SourceMissingThreshold, 1)
if sourceSeen {
if d.SourceMissCount != 0 {
if err := w.store.SetSourceMissCount(ctx, d.ID, 0); err != nil {
logctx.From(ctx).Warn("reconcile reset miss count failed", "error", err)
}
}
return true
}
miss := d.SourceMissCount + 1
if err := w.store.SetSourceMissCount(ctx, d.ID, miss); err != nil {
logctx.From(ctx).Warn("reconcile bump miss count failed", "error", err)
}
// До порога источник трактуется как присутствующий — задача не дёргается.
return miss < threshold
}
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
// считается присутствующей, только если существуют ВСЕ ссылки последнего
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return false, fmt.Errorf("latest batch: %w", err)
}
if batch == "" {
return false, nil // ничего не разложено — цели нет
}
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
if err != nil {
return false, fmt.Errorf("list links: %w", err)
}
laidOut := 0
for _, r := range rows {
// Все статусы, означающие реальный файл на диске: только что слинкован,
// скопирован (фолбэк) или уже существовал тем же inode (идемпотентный
// повтор apply). Прочие (collision и т.п.) — не наша разложенная цель.
if !isLaidOut(r.Status) {
continue
}
laidOut++
if _, err := os.Lstat(r.DstPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("stat target %q: %w", r.DstPath, err)
}
}
return laidOut > 0, nil
}
// isLaidOut сообщает, означает ли статус file_link реально лежащий на ФС файл
// нашей раскладки (а не коллизию или иной не-разложенный исход).
func isLaidOut(status string) bool {
switch layout.LinkStatus(status) {
case layout.StatusLinked, layout.StatusCopied, layout.StatusExists:
return true
default:
return false
}
}
// reconcileReason — человекочитаемая причина перехода для лога/UI.
func reconcileReason(sourcePresent, targetPresent bool) string {
switch {
case sourcePresent && targetPresent:
return "источник и цель на месте"
case sourcePresent && !targetPresent:
return "цель удалена, источник на месте"
case !sourcePresent && targetPresent:
return "источник удалён, цель (последняя копия) на месте"
default:
return "удалены и источник, и цель"
}
}
// --- Синхронный preflight перед действием (не доверяем state в БД) ---
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
if !d.Infohash.Valid {
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
}
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
if ok {
return nil
}
// Источник пропал — немедленно приводим состояние к реальности.
w.reconcileToReality(ctx, *d, false)
return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict)
}
// reconcileToReality выводит и проставляет состояние по уже известному факту об
// источнике (sourcePresent) и фактически проверенной цели. Используется
// preflight-проверками: немедленно, без дебаунса.
func (w *Worker) reconcileToReality(ctx context.Context, d store.Download, sourcePresent bool) {
targetPresent, err := w.targetPresent(ctx, d.ID)
if err != nil {
logctx.FromOr(ctx, w.log).Warn("preflight target probe failed", "error", err)
return
}
want := deriveState(sourcePresent, targetPresent)
if want == d.State {
return
}
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
}
+186
View File
@@ -0,0 +1,186 @@
package worker
import (
"context"
"os"
"path/filepath"
"testing"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// reconcileFixture готовит done-задачу с одной разложенной ссылкой: цель —
// файл в temp-каталоге (создаётся при makeTarget), источник — наличие
// раздачи в fakeQbt (sourcePresent).
type reconcileFixture struct {
w *Worker
st *memStore
dst string
}
func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTarget bool) reconcileFixture {
t.Helper()
dir := t.TempDir()
dst := filepath.Join(dir, "Movie (2024).mkv")
src := filepath.Join(dir, "src.mkv")
if makeTarget {
if err := os.WriteFile(dst, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
}
st := newMemStore()
d := completedDownload(1)
d.State = state
st.put(d)
st.links = append(st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
Kind: "video", Status: "linked",
})
var torrents []qbt.Torrent
if sourcePresent {
torrents = []qbt.Torrent{{Hash: ihTest}}
}
w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil)
w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки)
return reconcileFixture{w: w, st: st, dst: dst}
}
func TestReconcileMatrix(t *testing.T) {
cases := []struct {
name string
source, target bool
want store.State
}{
{"источник+цель → done", true, true, store.StateDone},
{"цель удалена → target_missing", true, false, store.StateTargetMissing},
{"источник удалён → orphaned", false, true, store.StateOrphaned},
{"оба удалены → deleted", false, false, store.StateDeleted},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newReconcileFixture(t, store.StateDone, tc.source, tc.target)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
}
}
func TestReconcileHealing(t *testing.T) {
// Источник вернулся, цель на месте → orphaned лечится обратно в done.
f := newReconcileFixture(t, store.StateOrphaned, true, true)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
t.Errorf("state = %q, want done (healing)", got)
}
}
func TestReconcilePartialTargetLoss(t *testing.T) {
// Две ссылки, одна цель удалена → цель считается отсутствующей.
f := newReconcileFixture(t, store.StateDone, true, true)
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
Kind: "subtitle", Status: "linked",
})
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateTargetMissing {
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
}
}
func TestReconcileDebounce(t *testing.T) {
// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
// возврат источника лечит обратно и сбрасывает счётчик.
f := newReconcileFixture(t, store.StateDone, false, true)
f.w.cfg.SourceMissingThreshold = 3
for i := 1; i <= 2; i++ {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll %d: %v", i, err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
}
if got := f.st.downloads[1].SourceMissCount; got != i {
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
}
}
if err := f.w.Poll(context.Background()); err != nil { // третий промах
t.Fatalf("Poll 3: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
}
// Источник вернулся.
f.w.qbt.(*fakeQbt).torrents = []qbt.Torrent{{Hash: ihTest}}
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll heal: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
t.Errorf("state = %q, want done (источник вернулся)", got)
}
if got := f.st.downloads[1].SourceMissCount; got != 0 {
t.Errorf("miss = %d, want 0 (сброс)", got)
}
}
func TestReconcileSkipsActiveStates(t *testing.T) {
// downloading сверкой не трогаем, даже если раздачи нет в qBittorrent.
f := newReconcileFixture(t, store.StateDownloading, false, true)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDownloading {
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
}
}
func TestUndoRejectedForOrphaned(t *testing.T) {
f := newReconcileFixture(t, store.StateOrphaned, false, true)
err := f.w.Undo(context.Background(), 1)
if err == nil {
t.Fatal("ожидали отказ Undo для orphaned")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (без изменений)", got)
}
}
func TestRelinkFromTargetMissing(t *testing.T) {
// target_missing + источник на месте → relink ведёт в recognizing.
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
if err := f.w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", got)
}
if f.st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
}
}
func TestPreflightFixesStaleState(t *testing.T) {
// В БД target_missing (источник якобы есть), но фактически источник пропал,
// а цель на месте: relink немедленно приводит состояние к orphaned, не
// дожидаясь фоновой сверки.
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
if err := f.w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали отказ relink при пропавшем источнике")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
}
}
+27 -12
View File
@@ -238,7 +238,10 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: lookup torrent: %w", err)
}
if !ok {
return fmt.Errorf("apply: torrent not found")
// Источник исчез между ревью и применением — приводим состояние к
// реальности и отказываем (preflight, не доверяем state в БД).
w.reconcileToReality(ctx, *d, false)
return fmt.Errorf("apply: источник удалён из qBittorrent: %w", ErrConflict)
}
w.transition(ctx, *d, store.StateLinking, "", "")
@@ -306,17 +309,13 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if d.State != store.StateReverted && d.State != store.StateCancelled {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled): %w", id, d.State, ErrConflict)
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
}
if !d.Infohash.Valid {
return fmt.Errorf("relink: download %d has no infohash", id)
}
// Раздача должна ещё быть в qBittorrent — без неё распознавать нечего.
if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil {
return fmt.Errorf("relink: %w", terr)
} else if !ok {
return fmt.Errorf("relink: торрент не найден в qBittorrent")
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
// его отсутствии приводим состояние к реальности (orphaned/deleted).
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
return err
}
// Вернуть задачу в активную обработку можно, только если другой активной
// задачи на этот infohash нет (partial unique index по idempotency_key).
@@ -348,6 +347,9 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
if err != nil {
return err
}
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
logctx.From(ctx).Info("review re-recognizing without hint")
w.transition(ctx, *d, store.StateRecognizing, "", "")
@@ -367,6 +369,9 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
if err != nil {
return err
}
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
if err := w.store.AddHint(ctx, id, hint); err != nil {
return fmt.Errorf("refine: %w", err)
@@ -389,6 +394,9 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
if err != nil {
return err
}
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
return fmt.Errorf("set type: %w", err)
@@ -452,7 +460,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
}
// Undo снимает хардлинки последнего батча и переводит задачу в reverted.
// Источник недосягаем (раскладчик удаляет только пути под библиотекой).
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
func (w *Worker) Undo(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -464,6 +474,11 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
if err != nil {
return fmt.Errorf("undo: %w", err)
}
if d.State == store.StateOrphaned {
// Источник удалён → библиотечный хардлинк остался единственной копией.
// Откат сотрёт данные насовсем — отказываем явно.
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
}
if d.State != store.StateDone {
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
}
+17 -5
View File
@@ -155,7 +155,8 @@ func TestRerecognize_ReviewToRecognizing(t *testing.T) {
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err != nil {
t.Fatalf("Rerecognize: %v", err)
@@ -184,8 +185,10 @@ func TestRelink_TorrentMissing(t *testing.T) {
if err := w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
}
if st.downloads[1].State != store.StateReverted {
t.Errorf("state = %q, want reverted (без изменений)", st.downloads[1].State)
// Preflight приводит состояние к реальности: источника нет и цели нет
// (reverted — ссылки сняты) → deleted (см. state-reconciliation).
if st.downloads[1].State != store.StateDeleted {
t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads[1].State)
}
}
@@ -291,6 +294,13 @@ func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State,
return nil
}
func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n
}
return nil
}
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) {
for _, e := range m.recs {
if e.DownloadID == r.DownloadID {
@@ -578,7 +588,8 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Refine(context.Background(), 1, "это второй сезон"); err != nil {
t.Fatalf("Refine: %v", err)
@@ -599,7 +610,8 @@ func TestSetType(t *testing.T) {
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.SetType(context.Background(), 1, "series"); err != nil {
t.Fatalf("SetType: %v", err)
+14
View File
@@ -40,6 +40,7 @@ type Store interface {
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id int64, n int) error
// Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
@@ -90,6 +91,8 @@ type NotifyEvent string
const (
EventReview NotifyEvent = "review" // задача ждёт подтверждения
EventDone NotifyEvent = "done" // раскладка завершена
EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия
EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink
)
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
@@ -113,6 +116,9 @@ type Config struct {
PollInterval time.Duration
StuckAfter time.Duration // stalledDL дольше → stuck
MagnetTimeout time.Duration // metaDL дольше → failed
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
SourceMissingThreshold int
}
// Worker — поллер и владелец переходов.
@@ -235,6 +241,10 @@ func (w *Worker) Poll(ctx context.Context) error {
}
w.reconcile(ctx, d, t)
}
// Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС)
// — отдельно от активных, по двумерной матрице (см. state-reconciliation).
w.reconcileDesync(ctx, byHash)
return nil
}
@@ -293,6 +303,10 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
go w.notifier.Notify(context.Background(), d.ID, EventReview)
case store.StateDone:
go w.notifier.Notify(context.Background(), d.ID, EventDone)
case store.StateOrphaned:
go w.notifier.Notify(context.Background(), d.ID, EventOrphaned)
case store.StateTargetMissing:
go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing)
}
}
+9
View File
@@ -90,6 +90,15 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
}
d.SourceMissCount = n
return nil
}
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-29
@@ -0,0 +1,204 @@
## Context
`worker` ведёт FSM загрузки (см. [workflow.md](../../../docs/specs/workflow.md)).
Сейчас `Poll` сверяет с qBittorrent только задачи в `downloading`
(`ListDownloadsByState(StateDownloading)`); терминальные задачи (`done`) с
реальностью не сверяются вовсе. Если раздача исчезла из qBittorrent, для
активной задачи код лишь пишет `Warn("active download not found")`; для
`done` не делает ничего.
Удаление просмотренного контента — штатная эксплуатационная операция, и оно
ручное: из qBittorrent (источник + скачанные файлы) или из Jellyfin (наши
хардлинки). Два инварианта при этом ломаются молча:
- **«Источник неприкосновенен»** перестаёт держаться, когда источника уже
нет: библиотечный хардлинк становится последней копией, а `layout.Undo`
безусловно `unlink`-ает цель.
- **Состояние `done` правдиво** перестаёт держаться, когда файлы убрали из
библиотеки: ссылок нет, а БД числит `done`.
Данные, на которые опираемся: `download.infohash` (сопоставление с qBit),
`file_link.dst_path` + `status` (разложенные хардлинки), `file_link.src_path`
(исходный файл раздачи). qBittorrent уже листаем целиком в `Poll`.
## Goals / Non-Goals
**Goals:**
- Распознавать ручное удаление источника и/или цели фоновой сверкой и
отражать его в состоянии задачи — **без автоматических действий**.
- Не допускать потери данных при `Undo`, когда источник уже удалён.
- Сделать рассинхрон видимым (состояние в UI + уведомление автору).
- Сохранить самовосстановление: вернулась реальность — вернулось состояние.
**Non-Goals:**
- Удаление средствами jellybit (path 2, «единое окно»).
- Автоперезапуск распознавания/раскладки при рассинхроне (relink — вручную).
- Реакция на удаление файлов **внутри** живой раздачи (это `missingFiles`/
`error` qBittorrent — уже ведёт в `failed`).
- Ретеншн терминальных задач.
## Decisions
### D1. Двумерная матрица «источник × цель» → новые состояния FSM
Рассинхрон параметризуется двумя независимыми фактами. Для задачи, которая
дошла до раскладки, состояние выводится из них:
| источник (qBit) | цель (хардлинки) | состояние |
|-----------------|------------------|-----------------|
| есть | есть | `done` |
| есть | нет | `target_missing`|
| нет | есть | `orphaned` |
| нет | нет | `deleted` |
Выбрали **новые значения `state`**, а не флаги поверх состояния:
терминальность и набор доступных команд у этих ситуаций разные, а FSM —
единая точка правды (граф уже в `workflow.md`). Флаги размазали бы логику
«что можно делать» по двум осям.
- `target_missing` — источник на месте → доступен пользовательский relink
(та же команда «Привязать заново», что из `reverted`/`cancelled`:
`target_missing → recognizing`). Авто-переход в `recognizing` **не**
делаем — это нарушило бы «никаких автодействий».
- `orphaned` — источник пропал, цель (последняя копия) на месте. Команд
вперёд нет; `Undo` заблокирован (см. D4).
- `deleted` — пусто и там, и там; терминально, действий нет.
**Альтернатива (отклонено):** одно состояние `desynced` + поле-причина.
Хуже: разные исходы требуют разных команд и разной терминальности — проще
развести по состояниям.
### D2. Какие задачи и как сверяем
Desync-сверка применяется только к задачам, для которых ожидаем разложенные
файлы и осмысленную связь с источником: `done`, `target_missing`,
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/
`cancelled`/`failed`/`stuck`) сверка **не трогает** (`reverted` = мы сами
сняли ссылки, это не рассинхрон).
- **Источник присутствует**`download.infohash` найден среди торрентов
qBittorrent (карту `byHash` `Poll` уже строит).
- **Цель присутствует** ⟺ все `file_link` со `status = linked` существуют на
ФС (`os.Lstat`). Частичная пропажа (исчезла часть ссылок) трактуется как
«цель отсутствует» → `target_missing` (библиотека сломана, лечится
relink/слиянием).
Сверку делаем на том же тике `Poll` (5 с). При текущих объёмах (домашний
сервер) `Lstat` по ссылкам терминальных задач дёшев; если объём вырастет
(см. TODO «Ретеншн»), вынесем desync-сверку на отдельный, более редкий
интервал. Состояние выводим и переписываем только при изменении (без
лишних записей и логов на каждом тике).
### D3. Дебаунс пропажи источника
qBittorrent может временно пропасть из выдачи (рестарт демона, мигнул API),
тогда как локальный `Lstat` цели надёжен. Поэтому дебаунсим **только
отсутствие источника**:
- новый столбец `download.source_miss_count` (миграция goose);
- источник отсутствует на тике → `source_miss_count++`; найден →
сбрасываем в `0`;
- источник считается удалённым (для вывода состояния) лишь когда
`source_miss_count >= [worker].source_missing_threshold` (по умолчанию
`3` → ~15 с при интервале 5 с). До порога источник трактуется как
присутствующий — задача не дёргается.
Отсутствие цели в дебаунсе не нуждается (локальная ФС не «мигает»).
### D4. Безопасный `Undo`: не снимать последнюю копию
`layout.Undo` для каждой ссылки перед `unlink`:
1. `Lstat(dst)` — нет файла → нечего снимать, пропускаем (идемпотентно).
2. Иначе читаем `nlink` цели. `nlink <= 1` означает: это **единственная**
ссылка на inode (источник уже удалён) — `unlink` сотрёт данные. Отказ:
не трогаем файл, копим причину.
3. Доп. явный сигнал: `src_path` не существует → тоже отказ (источника нет).
Отказ возвращается типизированной ошибкой; задача в `reverted` **не**
переходит, причина показывается пользователю. На командном уровне `Undo`
для задачи в `orphaned` отклоняется сразу (по определению источник удалён →
все ссылки — последние копии); per-file `nlink`-проверка остаётся страховкой
на случай устаревшего состояния. Откат снимает **лишний** хардлинк, а не
последнюю копию.
### D5. Синхронный preflight перед действием (не доверяем `state` в БД)
Фоновая сверка (D2/D3) — eventual: она отстаёт на интервал поллинга плюс
дебаунс. Поэтому любая **команда**, которой нужен источник или цель, делает
собственную **синхронную пробу** прямо перед действием, а не полагается на
значение `state`:
- `relink`/«Распознать заново»/«Уточнить» и `Apply` (раскладка) — требуют
источника (раздача в qBittorrent);
- `Undo` — требует источника (чтобы не снять последнюю копию).
Чтобы не дублировать логику, выделяем чистый помощник
`probe(download) → (sourcePresent, targetPresent)` и `deriveState(...)`,
которые используют **и** фоновая сверка, **и** preflight — единая точка
правды о том, что есть на диске и как это отображается в состояние.
Ключевое отличие preflight от фона: **без дебаунса** — это явное действие
пользователя «сейчас», единичная проба. Если qBittorrent в этот момент
недоступен, команда честно отказывает («источник недоступен») — пользователь
повторит. Дебаунс нужен только фону, чтобы не дёргать состояние на
транзиентных пропажах.
При неуспехе предусловия команда не выполняет действие, прогоняет
`deriveState` (приводя `state` к реальности — напр. `done → orphaned`) и
возвращает пользователю причину. Так команда сама «лечит» устаревшее
состояние, не дожидаясь `worker`.
### D6. Самовосстановление (healing)
Состояние всегда выводится из текущей матрицы D1 (с учётом дебаунса D3), а
не «залипает». Если источник вернулся (раздачу добавили заново) или цель
снова на месте — следующая сверка переведёт задачу обратно
(`orphaned/target_missing/deleted → done`). `deleted` не делаем абсорбирующим
ради единообразия; на практике одновременный возврат маловероятен.
### D7. Видимость
При переходе в `orphaned`/`target_missing` `worker` шлёт уведомление автору
через существующий `notifier` (новые события `EventOrphaned`/
`EventTargetMissing`), как для `review`/`done`. Web-UI и Telegram
отображают новые состояния; для `orphaned` кнопка `Undo` скрыта/заблокирована
с пояснением «источник удалён — это последняя копия».
## Risks / Trade-offs
- **Ложная пометка при долгом простое qBittorrent** (рестарт дольше
`threshold × poll_interval`) → дебаунс D3 + самовосстановление D6: вернётся
раздача — вернётся `done`. Порог настраивается.
- **Стоимость `Lstat` на каждом тике** при росте числа терминальных задач →
при текущих объёмах пренебрежимо; путь отхода — отдельный редкий интервал
desync-сверки (зафиксировано в D2, делаем при необходимости).
- **`nlink` зависит от ФС/синтаксиса `syscall.Stat_t`** (Linux-таргет,
`CGO_ENABLED=0`, `linux/amd64`) → платформа фиксирована деплоем; copy-fallback
раскладки (не хардлинк) даёт `nlink==1` у легитимной копии — такой `Undo`
тоже корректно откажет (это и есть единственная копия). Приемлемо: лучше
отказать, чем удалить данные.
- **Гонки команда/сверка** → всё под `w.mu` per-download, как и остальные
переходы; новых блокировок не вводим.
## Migration Plan
- Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN
source_miss_count INTEGER NOT NULL DEFAULT 0`. Новые значения `state` —
данных не мигрируют (аддитивно).
- Конфиг: новый ключ `[worker].source_missing_threshold` с дефолтом; старые
конфиги валидны без него.
- Откат: безопасен — состояния перестанут проставляться, существующие
desync-задачи останутся со своим значением `state` (UI покажет как
неизвестное/как есть). Столбец можно не удалять.
## Open Questions
- Нужны ли пользователю команды из `orphaned`, кроме как ждать
восстановления (например явное «Забыть»/перевод в `deleted` руками)? На
старте — нет, только пометка; добавим, если будет спрос.
- Порог дебаунса по умолчанию (`3`) — уточнить по реальному времени
рестарта qBittorrent на umbar.
@@ -0,0 +1,87 @@
## Why
Пришло время удалять просмотренные фильмы/сериалы, чтобы освобождать место
под новые. Удаляют их **вручную** — из qBittorrent (источник) или из
Jellyfin (целевые хардлинки). Сейчас jellybit этого не замечает: `worker`
поллит только задачи в `downloading`, терминальные задачи (`done`) с
реальностью не сверяет. Состояние в БД молча расходится с диском:
- **Источник пропал, цель осталась.** qBittorrent стирает скачанные файлы
при удалении раздачи. Хардлинк в библиотеке становится **последней**
ссылкой на inode, а обычный `Undo` (`unlink` цели) сотрёт единственную
копию — прямая потеря данных. Инвариант «источник неприкосновенен»
молчаливо перестаёт держаться: источника уже нет.
- **Цель пропала, источник остался.** Файлы убрали из библиотеки, а
jellybit по-прежнему числит загрузку `done` — состояние врёт.
Цель этого change — **path 1**: научить jellybit корректно **распознавать**
ручное удаление и **отражать** его в состоянии, **не предпринимая
автоматических действий**. Удаление средствами самого jellybit («единое
окно», path 2) — отдельная будущая работа.
## What Changes
- **Фоновая сверка с реальностью.** `worker` расширяет периодический поллинг:
помимо `downloading` сверяет терминальные/desync-задачи с фактом на ФС —
присутствие раздачи в qBittorrent (источник) и существование разложенных
хардлинков `file_link.dst_path` (цель).
- **Новые состояния FSM** для рассинхрона (двумерная матрица «источник × цель»):
- `target_missing` — источник на месте, цель удалена: доступен
пользовательский флоу повторной привязки (relink); авто-действий нет.
- `orphaned` — источник пропал, цель на месте: «осиротевшая» раздача,
библиотечный хардлинк — единственная копия.
- `deleted` — пропали и источник, и цель: терминально, действий больше нет.
- Реальность «лечится» сама: если источник/цель снова появились, сверка
возвращает задачу в согласованное состояние.
- **Дебаунс пропажи источника.** Раздача считается удалённой только после
`N` подряд тиков без неё (qBittorrent мог рестартовать / API мигнул);
любое появление сбрасывает счётчик. Порог — в конфиге.
- **Защита `Undo` от потери данных.** `Undo`/`layout.Undo` отказывается
снимать хардлинк, если он **последняя копия** (`nlink == 1`) или исходный
путь не существует — откат снимает лишний хардлинк, а не единственный
файл; причина отказа сообщается явно.
- **Уведомление** автору загрузки при переходе в `orphaned`/`target_missing`
(через существующий `notifier`), чтобы рассинхрон не оставался незаметным.
Не входит в объём (Non-goals):
- Удаление раздач/файлов средствами самого jellybit (path 2, «единое окно»).
- Автоматический повторный прогон распознавания/раскладки при рассинхроне —
только пометка состояния; relink инициирует человек.
- Сверка содержимого источника (manual delete файлов **внутри** живой
раздачи → это `error`/`missingFiles` qBittorrent, отдельная тема).
- Ретеншн/авточистка терминальных задач (отдельная задача в TODO).
## Capabilities
### New Capabilities
- `state-reconciliation`: периодическая сверка записанного состояния
загрузки с фактом на ФС (раздача в qBittorrent, разложенные хардлинки),
состояния рассинхрона (`target_missing`/`orphaned`/`deleted`), их переходы
и дебаунс; а также инвариант безопасного `Undo` (не снимать последнюю
копию).
### Modified Capabilities
<!-- Граф состояний и Undo живут в docs/specs/workflow.md и
docs/specs/jellyfin-layout.md и ещё не перенесены в OpenSpec; меняем их
напрямую как живые спеки (см. Impact). Capability-дельт для них нет. -->
## Impact
- **Спеки:** новая `openspec/specs/state-reconciliation/`; правки живых
`docs/specs/workflow.md` (граф состояний: +`target_missing`/`orphaned`/
`deleted`, переходы) и `docs/specs/jellyfin-layout.md` (инвариант
безопасного `Undo`).
- **Код:** `internal/worker` (расширение `Poll`/`reconcile` на терминальные
задачи, дебаунс, новые переходы, уведомления), `internal/store` (новые
значения `state`, столбец счётчика промахов, миграция, запросы выборки
desync-задач), `internal/layout` (`Undo` с проверкой `nlink`/наличия
источника), `internal/qbt` (присутствие infohash — уже листаем все
торренты), `internal/httpapi` + web-UI (отображение новых состояний,
блокировка `Undo` для `orphaned`), `internal/config` (`[worker]` порог
дебаунса).
- **Конфиг:** новый ключ в `[worker]` (порог пропусков источника).
- **Совместимость:** новые значения `state` — аддитивно; миграция goose для
столбца счётчика.
@@ -0,0 +1,189 @@
## ADDED Requirements
### Requirement: Периодическая сверка состояния с реальностью
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС).
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
#### Scenario: Источник и цель на месте — состояние не меняется
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются
#### Scenario: Частичная пропажа цели считается отсутствием
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
### Requirement: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно
проверять их присутствие непосредственно перед выполнением действия и SHALL
NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал
поллинга и дебаунс). Проверка перед действием выполняется как **единичная
немедленная проба без дебаунса**: дебаунс применяется только к фоновому
авто-маркированию.
Под это требование подпадают как минимум: повторная привязка/распознавание
(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют
**источника**; `Undo` — требует **источника** (чтобы не снять последнюю
копию).
Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL
привести состояние задачи в соответствие с реальностью (вывести состояние из
матрицы «источник × цель», как при сверке) и SHALL сообщить причину
пользователю.
#### Scenario: Relink проверяет источник перед запуском
- **WHEN** пользователь даёт команду, требующую источника (например
«Привязать заново»)
- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед
запуском распознавания
- **AND** если источника нет — распознавание не запускается, задача
приводится к `orphaned` либо `deleted` (по наличию цели), причина
сообщается
#### Scenario: Проверка не ждёт фоновую сверку
- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД
состояние, например, `done`)
- **THEN** команда, требующая источника, немедленно обнаруживает его
отсутствие собственной проверкой и отказывает, не дожидаясь `worker`
### Requirement: Состояние target_missing и доступность повторной привязки
Когда у задачи источник присутствует, а цель отсутствует, система SHALL
переводить её в состояние `target_missing` и SHALL NOT предпринимать
автоматических действий (не запускать повторное распознавание/раскладку
самостоятельно).
Из `target_missing` система SHALL предоставлять пользовательскую команду
повторной привязки (relink) с переходом `target_missing → recognizing`, как
из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным
подтверждением.
#### Scenario: Цель удалена, источник на месте
- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done`
больше нет, но раздача в qBittorrent присутствует
- **THEN** задача переходит в `target_missing`
- **AND** система не запускает распознавание или раскладку автоматически
#### Scenario: Пользователь инициирует повторную привязку
- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать
заново»
- **THEN** задача переходит в `recognizing` и далее проходит через `review`
с ручным подтверждением
### Requirement: Состояние orphaned при пропаже источника
Система SHALL переводить задачу в состояние `orphaned`, когда источник
отсутствует (с учётом дебаунса), а цель присутствует, отражая, что
библиотечный хардлинк остался единственной копией данных.
#### Scenario: Источник удалён, цель на месте
- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в
qBittorrent, а её разложенные хардлинки существуют
- **THEN** задача переходит в `orphaned`
### Requirement: Состояние deleted при пропаже источника и цели
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
переводить задачу в состояние `deleted`. В `deleted` действий над задачей
больше нет.
#### Scenario: Источник и цель удалены
- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
хардлинков задачи на ФС больше нет
- **THEN** задача переходит в `deleted`
### Requirement: Дебаунс пропажи источника
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных
пометок: источник считается удалённым лишь после `N` подряд тиков сверки без
него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи
SHALL сбрасывать счётчик пропусков.
Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС
надёжна).
#### Scenario: Кратковременная пропажа источника не помечается
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
тиков подряд
- **THEN** источник трактуется как присутствующий и состояние задачи не
меняется
#### Scenario: Возврат источника сбрасывает счётчик
- **WHEN** раздача снова обнаружена в qBittorrent
- **THEN** счётчик пропусков источника сбрасывается в ноль
### Requirement: Самовосстановление состояния при возврате реальности
Система SHALL возвращать задачу в согласованное состояние, когда реальность
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
при возврате источника и/или цели задача SHALL переходить из
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда
присутствуют оба).
#### Scenario: Источник вернулся
- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
цель по-прежнему на месте
- **THEN** задача возвращается в `done`
### Requirement: Безопасный Undo не снимает последнюю копию
`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую
ссылку, если она является последней копией данных: целевой файл существует и
его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не
существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и
SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить
SHALL NOT.
Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как
успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned`
SHALL отклоняться сразу с пояснением, что источник удалён.
#### Scenario: Отказ снять единственную копию
- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или
исходный файл отсутствует)
- **THEN** система не удаляет файл и сообщает, что это последняя копия
- **AND** задача остаётся в текущем состоянии (не `reverted`)
#### Scenario: Undo снимает лишний хардлинк при живом источнике
- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и
`nlink > 1`
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
### Requirement: Уведомление о рассинхроне
При переходе задачи в `orphaned` или `target_missing` система SHALL
уведомлять автора загрузки через настроенный механизм уведомлений
(`notifier`), чтобы рассинхрон не оставался незамеченным.
#### Scenario: Уведомление при потере источника
- **WHEN** задача переходит в `orphaned`
- **THEN** система отправляет автору загрузки уведомление о потере источника
@@ -0,0 +1,95 @@
## 1. Хранилище и состояния
- [x] 1.1 Добавить значения состояний `target_missing`, `orphaned`, `deleted`
в `internal/store` (константы `State*`) и в перечень допустимых состояний.
- [x] 1.2 Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN
source_miss_count INTEGER NOT NULL DEFAULT 0`; обновить модель `Download`.
- [x] 1.3 Запросы в `store`: выборка desync-кандидатов
(`done`/`target_missing`/`orphaned`/`deleted`), чтение/сброс/инкремент
`source_miss_count`, чтение `file_link` (`status = linked`) по задаче.
## 2. Конфигурация
- [x] 2.1 Добавить `[worker].source_missing_threshold` (int, дефолт `3`) в
`internal/config` с валидацией (`>= 1`); пробросить в `worker.Config`.
- [x] 2.2 Отразить ключ в примере конфига и `docs/conventions/config.md`/
README, если там перечислены ключи `[worker]`.
## 3. Сверка с реальностью (worker)
- [x] 3.1 Выделить общий помощник `probe(download) → (sourcePresent,
targetPresent)` (источник: infohash в qBit; цель: `Lstat` всех
`file_link.dst_path` со `status = linked`, частичная пропажа = цель
отсутствует) и `deriveState(src, tgt) → State` — единая точка правды для
фона и preflight.
- [x] 3.2 В `Poll` для desync-кандидатов вызвать `probe` и реализовать
дебаунс источника: инкремент `source_miss_count` при отсутствии, сброс при
обнаружении; «источник удалён» только при `source_miss_count >= threshold`.
- [x] 3.3 Применить `deriveState` и переходить только при изменении:
`done`/`target_missing`/`orphaned`/`deleted` + healing обратно в `done`.
Переиспользовать `transition`.
- [x] 3.4 Не трогать фоновой сверкой активные и пользовательски-терминальные
состояния (`reverted`/`cancelled`/`failed`/`stuck` и активные).
## 3a. Синхронный preflight перед действием
- [x] 3a.1 Перед командами, требующими источника/цели (relink, «Распознать
заново», «Уточнить», `Apply`, `Undo`), вызывать `probe` **без дебаунса**
(единичная немедленная проба), не доверяя `state` в БД.
- [x] 3a.2 При неуспехе предусловия: действие не выполнять, прогнать
`deriveState` (привести `state` к реальности, напр. `done → orphaned`),
вернуть пользователю причину; при недоступности qBittorrent — отказ
«источник недоступен».
## 4. Безопасный Undo (layout + worker)
- [x] 4.1 `internal/layout` `Undo`: для каждой ссылки `Lstat(dst)` (нет —
пропустить идемпотентно), иначе проверить `nlink <= 1` и наличие
`src_path`; при «последней копии» — отказ с типизированной ошибкой, без
`unlink`.
- [x] 4.2 `worker.Undo`: отклонять команду для задачи в `orphaned` сразу с
понятным сообщением; при отказе `layout.Undo` — не переводить в `reverted`,
пробросить причину пользователю.
- [x] 4.3 Разрешить переход `target_missing → recognizing` в команде
«Привязать заново» (наряду с `reverted`/`cancelled`).
## 5. Уведомления
- [x] 5.1 Добавить события `EventOrphaned`/`EventTargetMissing` и слать
уведомление автору в `transition` при входе в эти состояния (как для
`review`/`done`), неблокирующе и вне `w.mu`.
## 6. Транспорты (httpapi + web-UI)
- [x] 6.1 Отобразить новые состояния в списке/карточке загрузки (метки,
пояснения «разложено, но файлов нет» / «источник удалён — последняя копия»).
- [x] 6.2 Скрыть/заблокировать `Undo` для `orphaned`; показать команду
«Привязать заново» для `target_missing`.
## 7. Тесты
- [x] 7.1 Таблица переходов сверки: все четыре ячейки матрицы + healing,
частичная пропажа цели → `target_missing`.
- [x] 7.2 Дебаунс: пропажа < порога не помечает; >= порога помечает; возврат
сбрасывает счётчик.
- [x] 7.3 `layout.Undo`: отказ при `nlink <= 1`/отсутствии `src_path`;
снятие лишнего хардлинка при живом источнике; пропуск отсутствующей цели.
- [x] 7.4 `worker.Undo` отклоняется для `orphaned`; relink из
`target_missing` ведёт в `recognizing`.
- [x] 7.5 Preflight: команда с устаревшим `state = done`, но удалённым
источником немедленно отказывает и приводит состояние к `orphaned`/
`deleted` (не дожидаясь фоновой сверки).
## 8. Документация и спеки
- [x] 8.1 Обновить `docs/specs/workflow.md`: граф состояний (+`target_missing`/
`orphaned`/`deleted`, переходы, healing) и описания.
- [x] 8.2 Обновить `docs/specs/jellyfin-layout.md`: инвариант безопасного
`Undo` (не снимать последнюю копию).
- [x] 8.2a Обновить ER-схему `docs/specs/database.md`: столбец
`download.source_miss_count` + отметка миграции `0003` (конвенция:
схема едет вместе с миграцией).
- [x] 8.3 Снять пункт «Рассинхрон состояния с реальностью» (часть про
detection/marking и undo-guard) из `docs/todo.md` или сузить до path 2.
- [x] 8.4 `openspec validate --strict`; ревью кода; затем `opsx:archive`
(влить дельту `state-reconciliation` в `openspec/specs/`).
+201
View File
@@ -0,0 +1,201 @@
# state-reconciliation Specification
## Purpose
Сверка записанного состояния загрузки с фактом на файловой системе и в
qBittorrent. Capability описывает периодическую и принудительную проверку
присутствия **источника** (раздача в qBittorrent) и **цели** (разложенные
хардлинки), вывод состояний рассинхрона (`target_missing`/`orphaned`/
`deleted`) из матрицы «источник × цель», их переходы и самовосстановление,
дебаунс пропажи источника, инвариант безопасного `Undo` (не снимать
последнюю копию) и уведомления о рассинхроне.
## Requirements
### Requirement: Периодическая сверка состояния с реальностью
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС).
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
#### Scenario: Источник и цель на месте — состояние не меняется
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются
#### Scenario: Частичная пропажа цели считается отсутствием
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
### Requirement: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно
проверять их присутствие непосредственно перед выполнением действия и SHALL
NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал
поллинга и дебаунс). Проверка перед действием выполняется как **единичная
немедленная проба без дебаунса**: дебаунс применяется только к фоновому
авто-маркированию.
Под это требование подпадают как минимум: повторная привязка/распознавание
(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют
**источника**; `Undo` — требует **источника** (чтобы не снять последнюю
копию).
Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL
привести состояние задачи в соответствие с реальностью (вывести состояние из
матрицы «источник × цель», как при сверке) и SHALL сообщить причину
пользователю.
#### Scenario: Relink проверяет источник перед запуском
- **WHEN** пользователь даёт команду, требующую источника (например
«Привязать заново»)
- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед
запуском распознавания
- **AND** если источника нет — распознавание не запускается, задача
приводится к `orphaned` либо `deleted` (по наличию цели), причина
сообщается
#### Scenario: Проверка не ждёт фоновую сверку
- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД
состояние, например, `done`)
- **THEN** команда, требующая источника, немедленно обнаруживает его
отсутствие собственной проверкой и отказывает, не дожидаясь `worker`
### Requirement: Состояние target_missing и доступность повторной привязки
Когда у задачи источник присутствует, а цель отсутствует, система SHALL
переводить её в состояние `target_missing` и SHALL NOT предпринимать
автоматических действий (не запускать повторное распознавание/раскладку
самостоятельно).
Из `target_missing` система SHALL предоставлять пользовательскую команду
повторной привязки (relink) с переходом `target_missing → recognizing`, как
из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным
подтверждением.
#### Scenario: Цель удалена, источник на месте
- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done`
больше нет, но раздача в qBittorrent присутствует
- **THEN** задача переходит в `target_missing`
- **AND** система не запускает распознавание или раскладку автоматически
#### Scenario: Пользователь инициирует повторную привязку
- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать
заново»
- **THEN** задача переходит в `recognizing` и далее проходит через `review`
с ручным подтверждением
### Requirement: Состояние orphaned при пропаже источника
Система SHALL переводить задачу в состояние `orphaned`, когда источник
отсутствует (с учётом дебаунса), а цель присутствует, отражая, что
библиотечный хардлинк остался единственной копией данных.
#### Scenario: Источник удалён, цель на месте
- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в
qBittorrent, а её разложенные хардлинки существуют
- **THEN** задача переходит в `orphaned`
### Requirement: Состояние deleted при пропаже источника и цели
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
переводить задачу в состояние `deleted`. В `deleted` действий над задачей
больше нет.
#### Scenario: Источник и цель удалены
- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
хардлинков задачи на ФС больше нет
- **THEN** задача переходит в `deleted`
### Requirement: Дебаунс пропажи источника
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных
пометок: источник считается удалённым лишь после `N` подряд тиков сверки без
него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи
SHALL сбрасывать счётчик пропусков.
Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС
надёжна).
#### Scenario: Кратковременная пропажа источника не помечается
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
тиков подряд
- **THEN** источник трактуется как присутствующий и состояние задачи не
меняется
#### Scenario: Возврат источника сбрасывает счётчик
- **WHEN** раздача снова обнаружена в qBittorrent
- **THEN** счётчик пропусков источника сбрасывается в ноль
### Requirement: Самовосстановление состояния при возврате реальности
Система SHALL возвращать задачу в согласованное состояние, когда реальность
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
при возврате источника и/или цели задача SHALL переходить из
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда
присутствуют оба).
#### Scenario: Источник вернулся
- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
цель по-прежнему на месте
- **THEN** задача возвращается в `done`
### Requirement: Безопасный Undo не снимает последнюю копию
`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую
ссылку, если она является последней копией данных: целевой файл существует и
его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не
существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и
SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить
SHALL NOT.
Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как
успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned`
SHALL отклоняться сразу с пояснением, что источник удалён.
#### Scenario: Отказ снять единственную копию
- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или
исходный файл отсутствует)
- **THEN** система не удаляет файл и сообщает, что это последняя копия
- **AND** задача остаётся в текущем состоянии (не `reverted`)
#### Scenario: Undo снимает лишний хардлинк при живом источнике
- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и
`nlink > 1`
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
### Requirement: Уведомление о рассинхроне
При переходе задачи в `orphaned` или `target_missing` система SHALL
уведомлять автора загрузки через настроенный механизм уведомлений
(`notifier`), чтобы рассинхрон не оставался незамеченным.
#### Scenario: Уведомление при потере источника
- **WHEN** задача переходит в `orphaned`
- **THEN** система отправляет автору загрузки уведомление о потере источника
+4
View File
@@ -28,6 +28,9 @@
.state-failed { background: #e74c3c55; }
.state-cancelled { background: #95a5a655; }
.state-reverted { background: #95a5a655; }
.state-target_missing { background: #e67e2255; }
.state-orphaned { background: #e74c3c88; }
.state-deleted { background: #95a5a655; }
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; }
small { color: #8888; }
@@ -57,6 +60,7 @@
<td>{{.Context}}</td>
<td>
<span class="state state-{{.State}}">{{.State}}</span>
{{if .Note}}<br><small>{{.Note}}</small>{{end}}
{{if .Error}}<br><small>{{.Error}}</small>{{end}}
</td>
<td>