# 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`) состояния сверка по матрице трогать SHALL NOT. **Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или `stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой) сверка SHALL рассматривать отдельно — на предмет оживления источника (см. требование о восстановлении зависшей загрузки), не по матрице «источник × цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT. Состояние SHALL переписываться только при его изменении (без записи и логов, когда выведенное состояние совпадает с текущим). #### Scenario: Источник и цель на месте — состояние не меняется - **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её разложенные хардлинки существуют - **THEN** задача остаётся в `done` - **AND** запись состояния и лог перехода не выполняются #### Scenario: Частичная пропажа цели считается отсутствием - **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте - **THEN** цель считается отсутствующей и задача переходит в `target_missing` #### Scenario: Задача в deleted сверкой не переоценивается - **WHEN** задача находится в `deleted` - **THEN** сверка её не рассматривает и состояние не меняет, даже если по её бывшему пути появился файл другой загрузки #### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит - **WHEN** задача в `failed` с `error_code` `qbit_error` - **THEN** сверка её не рассматривает и состояние не меняет ### Requirement: Восстановление зависшей загрузки при оживлении источника Система SHALL возвращать в активный поток задачу, упавшую из-за нашей нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её источник в qBittorrent жив и продвинулся: переход выводится из текущего состояния торрента так же, как при штатной сверке загрузки (`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… → `downloading`). Восстановление SHALL опираться на фактическое состояние торрента в qBittorrent, а не на время с момента создания записи. При возврате в любое нетерминальное состояние (`downloading` или `completed`) система SHALL восстанавливать идемпотентность задачи (`idempotency_key`), чтобы повторный приём того же infohash снова дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем же infohash уже завладела другая активная задача (ключ снимается при падении и мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более одной активной задачи на infohash». `magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck` из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим метаданным в `docs/specs/workflow.md`). #### Scenario: Метаданные пришли после magnet_timeout - **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент в qBittorrent уже получил метаданные и качается (`downloading`) - **WHEN** срабатывает фоновая сверка - **THEN** задача возвращается в `downloading` - **AND** её `idempotency_key` восстанавливается #### Scenario: Торрент уже завершился, пока задача была в failed - **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`) - **WHEN** срабатывает фоновая сверка - **THEN** задача переходит в `completed` и продолжает обычный поток (распознавание/раскладка) #### Scenario: Источник так и не ожил — состояние не меняется - **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent) - **WHEN** срабатывает фоновая сверка - **THEN** задача остаётся в `failed` #### Scenario: infohash уже занят другой активной задачей - **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей) - **WHEN** источник ожил (торрент получил метаданные или готов) и сверка пытается воскресить #1 - **THEN** #1 остаётся в `failed` (восстановление не выполняется) - **AND** активной по этому infohash остаётся #2 ### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов Система SHALL предоставлять пользователю команду повторной попытки (retry) для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API). Retry SHALL переводить задачу обратно в `downloading`, не вызывая её немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого `created_at`). Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к существующему торренту, а не добавлять источник повторно вслепую; повторный `Add` выполняется, только когда раздачи в qBittorrent нет. #### Scenario: Retry упавшей magnet-загрузки из веб-UI - **GIVEN** задача в `failed`, её торрент жив в qBittorrent - **WHEN** пользователь нажимает retry в веб-UI - **THEN** задача возвращается в `downloading` без повторного `Add` - **AND** не падает снова на ближайшем тике сверки по таймауту #### Scenario: Retry доступен в Telegram - **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в Telegram-боте - **THEN** задача возвращается в `downloading` #### Scenario: Retry без живого источника добавляет торрент заново - **GIVEN** задача в `failed`, раздачи в qBittorrent нет - **WHEN** пользователь инициирует retry - **THEN** источник (magnet) добавляется в qBittorrent заново - **AND** задача переходит в `downloading` ### 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` #### 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** система отправляет автору загрузки уведомление о потере источника