Files
jellybit/openspec/specs/state-reconciliation/spec.md
T
avandClaude Opus 4.8 6b7c090ce4 Владение целевым путём при повторной раскладке (state-reconciliation)
Завершённая загрузка ложно «воскресала» из deleted в orphaned, когда её
целевой путь переиспользовала другая загрузка (повторная закачка того же
фильма в другом качестве): сверка проверяла лишь существование пути, не
проверяя, что файл по нему — наша раскладка.

Вводим инвариант «один целевой путь — один владелец»:

- при успешной раскладке на освободившийся чужой путь владение переходит
  к новой загрузке — прежние file_link на этот путь помечаются статусом
  superseded и перестают считаться целью при сверке;
- deleted исключён из desyncStates — терминальное состояние больше не
  переоценивается (источник к нему не вернётся из-за идемпотентности,
  цель отбирается переходом владения);
- Undo снимает только реально свои разложенные ссылки (superseded
  пропускает — файл по пути теперь чужой хардлинк);
- ошибку перехода владения трактуем как некритичную (WARN-and-continue):
  файлы уже разложены, рассинхрон чужих задач исправит следующий тик.

Без миграции схемы (status — TEXT). Дельта влита в основную спеку,
обновлены workflow.md и jellyfin-layout.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:10:05 +03:00

18 KiB
Raw Blame History

state-reconciliation Specification

Purpose

Сверка записанного состояния загрузки с фактом на файловой системе и в qBittorrent. Capability описывает периодическую и принудительную проверку присутствия источника (раздача в qBittorrent) и цели (разложенные хардлинки), вывод состояний рассинхрона (target_missing/orphaned/ deleted) из матрицы «источник × цель», их переходы и самовосстановление, дебаунс пропажи источника, инвариант безопасного Undo (не снимать последнюю копию) и уведомления о рассинхроне.

Requirements

Requirement: Периодическая сверка состояния с реальностью

worker SHALL периодически (на тике поллинга) сверять задачи, для которых ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и выводить состояние задачи из двух независимых признаков: присутствия источника (раздача с download.infohash в выдаче qBittorrent) и присутствия цели (см. требование о владении целевым путём: существуют все ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой загрузке).

Сверке SHALL подвергаться только состояния done, target_missing, orphaned. Состояние deleted сверка трогать SHALL NOT — оно терминально. Активные (downloading/recognizing/review/deferred/linking) и пользовательски-терминальные (reverted/cancelled/failed/stuck) состояния сверка трогать SHALL NOT.

Состояние SHALL переписываться только при его изменении (без записи и логов, когда выведенное состояние совпадает с текущим).

Scenario: Источник и цель на месте — состояние не меняется

  • WHEN для задачи в done раздача присутствует в qBittorrent и все её разложенные хардлинки существуют
  • THEN задача остаётся в done
  • AND запись состояния и лог перехода не выполняются

Scenario: Частичная пропажа цели считается отсутствием

  • WHEN часть разложенных хардлинков задачи удалена, а источник на месте
  • THEN цель считается отсутствующей и задача переходит в target_missing

Scenario: Задача в deleted сверкой не переоценивается

  • WHEN задача находится в deleted
  • THEN сверка её не рассматривает и состояние не меняет, даже если по её бывшему пути появился файл другой загрузки

Requirement: Принудительная проверка источника/цели перед действием

Команда workflow, требующая наличия источника или цели, SHALL синхронно проверять их присутствие непосредственно перед выполнением действия и SHALL NOT полагаться только на фоновую сверку worker (она отстаёт на интервал поллинга и дебаунс). Проверка перед действием выполняется как единичная немедленная проба без дебаунса: дебаунс применяется только к фоновому авто-маркированию.

Под это требование подпадают как минимум: повторная привязка/распознавание (relink, «Распознать заново», «Уточнить») и раскладка (Apply) — требуют источника; Undo — требует источника (чтобы не снять последнюю копию).

Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL привести состояние задачи в соответствие с реальностью (вывести состояние из матрицы «источник × цель», как при сверке) и SHALL сообщить причину пользователю.

  • 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

Scenario: deleted не воскресает при переиспользовании пути

  • GIVEN задача A в deleted
  • WHEN другая задача раскладывается по бывшему пути A
  • THEN задача A остаётся в deleted (не переходит в orphaned)

Requirement: Владение целевым путём — один путь, один владелец

Целевой путь раскладки (file_link.dst_path) SHALL принадлежать не более чем одной загрузке одновременно. При успешной раскладке загрузки на путь, который ранее заняла другая загрузка, владение SHALL переходить к новой загрузке: ссылки прежней загрузки на тот же dst_path система SHALL помечать вышедшими из обращения (статус, не относящийся к разложенной цели), после чего они перестают считаться целью прежней загрузки при сверке.

Присутствие цели при сверке SHALL определяться по владению, а не по факту существования пути: цель загрузки считается присутствующей, только если существующие на ФС файлы по её путям — это ссылки, всё ещё принадлежащие этой загрузке (не вышедшие из обращения). Файл, лежащий по тому же пути, но созданный другой загрузкой, целью первой загрузки считаться SHALL NOT.

Переход владения возможен лишь когда путь к моменту раскладки свободен (прежний файл уже удалён): занятый реальным файлом путь по-прежнему даёт коллизию и уходит в review (новая раскладка не перезаписывает чужой файл).

Scenario: Повторная закачка забирает освободившийся путь

  • GIVEN загрузка A разложена по пути P, но её файл по P удалён вручную
  • WHEN загрузка B успешно раскладывается по тому же пути P
  • THEN ссылки A на P помечаются вышедшими из обращения
  • AND при сверке цель A по пути P считается отсутствующей

Scenario: Чужой файл по пути не считается своей целью

  • GIVEN по пути P лежит файл, созданный загрузкой B
  • WHEN сверка проверяет присутствие цели загрузки A, чьи ссылки на P вышли из обращения
  • THEN цель A считается отсутствующей, несмотря на существование файла по P

Scenario: Занятый путь даёт коллизию, а не переход владения

  • GIVEN файл загрузки A по пути P всё ещё существует
  • WHEN загрузка B пытается разложиться по тому же пути P
  • THEN возникает коллизия и B уходит в review
  • AND владение путём P за A не отбирается

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 обратно (в т.ч. в done, когда присутствуют оба). Из терминального deleted самовосстановления SHALL NOT быть.

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 система отправляет автору загрузки уведомление о потере источника