From b9391923488c7e2fb2ee28f4d8ccc19a88d7d534 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 10 Aug 2026 17:51:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20bulk-delete-page,=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20=D1=82=D1=80?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B6=D0=B0=D1=8F=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tasks/BACKLOG.md | 4 +- tasks/items/bulk-delete-page.md | 64 ------------------ .../delete-checks-active-infohash-owner.md | 66 +++++++++++++++++++ tasks/items/delete-holds-worker-lock.md | 59 +++++++++++++++++ tasks/items/delete-leaves-stale-done.md | 63 ++++++++++++++++++ 5 files changed, 191 insertions(+), 65 deletions(-) delete mode 100644 tasks/items/bulk-delete-page.md create mode 100644 tasks/items/delete-checks-active-infohash-owner.md create mode 100644 tasks/items/delete-holds-worker-lock.md create mode 100644 tasks/items/delete-leaves-stale-done.md diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 0d75686..964bdce 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -27,7 +27,8 @@ - [✨ Переделать веб-UI в устанавливаемое PWA](items/web-ui-pwa.md) — текущий server-rendered UI функционален — PWA (устанавливаемое, удобное с телефона) это улучшение большого объёма, не блокер - [✨ Править на ревью маппинг «файл → серия» и раскладывать вручную при провале LLM](items/review-mapping-editor.md) — правка S·E, «нумеровать подряд» и ручной режим при полном провале LLM были запланированы объёмом Ф5 и не заведены задачей — в ревью сегодня можно только подсказать текстом - [✨ Заказать спекой крайние случаи именования: многофайловый фильм, редакции, двойная серия](items/naming-edge-cases.md) — стэкинг частей (part1/cd1), редакции [edition-…] и двойная серия SxxEyy-Eyy описаны нарративом, но в file-layout не заказаны — раскладка таких раздач не определена -- [✨ Удалять выбранные загрузки с файлами на отдельной странице](items/bulk-delete-page.md) — удаление с файлами живёт только в danger-секции страницы одной загрузки: чтобы снести десять раздач, надо десять раз пройти путь список → карточка → подтверждение +- [🐞 Не сносить раздачу, которой владеет другая активная загрузка](items/delete-checks-active-infohash-owner.md) — удаление старой закрытой задачи уничтожает файлы живой загрузки с тем же инфохэшем; воспроизведено падающим тестом на ревью bulk-delete-page +- [🐞 Не оставлять задачу в done, когда ссылки сняты, а раздача не снесена](items/delete-leaves-stale-done.md) — при недоступном qBittorrent задача весь простой соседа показывает done, хотя тайтла в Jellyfin уже нет: сверка падает на первом шаге и до коррекции не доходит - [🔬 Канон нумерации серий и порядок у провайдера тега](items/episode-numbering-canon.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега - [🔬 Тексты и формат уведомлений в Telegram](items/telegram-messages-audit.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи - [🔬 guessit как сервис-спутник](items/guessit-sidecar.md) — go-ptn слабее питоновского guessit — если точности пред-парса не хватит, завернуть guessit в сервис-спутник рядом с бинарём @@ -48,6 +49,7 @@ - [✨ Чистить БД от терминальных задач и сырых ответов LLM старше срока хранения](items/db-retention-cleanup.md) — терминальные задачи и сырые ответы LLM копятся вечно — без авточистки список загрузок и БД деградируют по мере эксплуатации - [🧹 Свести термины домена в словарь единого языка](items/ubiquitous-language-glossary.md) — наименования домена расходятся между спеками, UI и кодом — нет единого глоссария (на нём же стоит агент-ревьювер наименований) - [🧹 Разобрать кандидатов по тестам и записать конвенцию](items/tests-convention.md) — как пишем тесты, не записано нигде: пункт «Тесты» в convention-candidates не пересматривали, он обещает фикстуры в testdata/, которых в проекте нет — а трение накопилось (четыре внешних клиента, fakeStore с инъекцией ошибок, env-гейты, флаки-прогон, diff-coverage) +- [🐞 Не держать общий замок воркера во время обращения к qBittorrent при удалении](items/delete-holds-worker-lock.md) — на время удаления встаёт весь фон: опрос, сверка и команды остальных транспортов ждут замок до 30 с на загрузку, пачкой — минутами; замерено на ревью bulk-delete-page - [🔬 Потолок нагрузки: 100 одновременных загрузок, план-максимум 1000](items/scale-100-downloads.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг) - [🔬 Завершение загрузки через webhook](items/completion-webhook.md) — завершение сейчас ловим поллингом qBittorrent — webhook реагировал бы быстрее, но связывает нас с его конфигом (решим по опыту эксплуатации) - [🔬 Кандидаты в конвенции кода](items/convention-candidates.md) — накоплен список кандидатов (внешние клиенты, конкурентность, тесты, CLI, время) — надо решить, что из них стало реальным трением, а что выдумано вперёд diff --git a/tasks/items/bulk-delete-page.md b/tasks/items/bulk-delete-page.md deleted file mode 100644 index 0332d96..0000000 --- a/tasks/items/bulk-delete-page.md +++ /dev/null @@ -1,64 +0,0 @@ -# ✨ Удалять выбранные загрузки с файлами на отдельной странице - -- **Тип:** feature -- **Категория:** Ядро продукта -- **Зачем:** удаление с файлами живёт только в danger-секции страницы одной загрузки: чтобы снести десять раздач, надо десять раз пройти путь список → карточка → подтверждение -- **Теги:** goal:bulk-download-management - -Отдельная страница веб-UI, доступная из шапки: список загрузок, у которых -удаление разрешено (`done`, `orphaned`, `target_missing` — -`internal/worker/review.go:631`), чекбокс у каждой строки и одна кнопка -«Удалить выбранные». Кнопка ведёт на подтверждение, где выбранные раздачи -названы поимённо, и только оттуда уходит удаление. - -Страница отдельная, чтобы не вешать режим выбора на основной список: там -карточки живые, самополлятся и перерисовываются, а выбор пользователя такое -перерисовывание переживать не обязан. - -Речь только о полном удалении (снять хардлинки + снести раздачу с файлами из -qBittorrent). Групповой `Dismiss` (смена статуса без файлов) в эту задачу не -входит. - -## Затрагивает - -- новая страница веб-UI и ссылка на неё в `web/templates/partials/header.html`; - новый шаблон страницы и её партиалы; -- новый POST-эндпоинт группового удаления в `internal/httpapi` (рядом с - `POST /ui/downloads/{id}/delete`, `internal/httpapi/httpapi.go:143`); -- `Reviewer.Delete` воркера (`internal/worker/review.go:619`) — зовётся по - каждой выбранной загрузке; -- qBittorrent `torrents/delete` с `deleteFiles=true` — внешний сервис, - необратимая операция; -- спека `web-ui`, требования «Страницы веб-UI» и «Действия соответствуют - состоянию»; -- спека `state-reconciliation` в части `Delete` — групповой вызов не меняет - условий поштучного, но подтверждение человека теперь одно на пачку. - -## Критерии приёмки - -- Страница открывается из шапки и показывает только те загрузки, для которых - удаление разрешено поштучно (оракул: тест `internal/httpapi` — подставной - читатель отдаёт задачи во всех состояниях, в разметке строки есть у - `done`/`orphaned`/`target_missing` и нет у остальных). -- Удаление уходит только после явного подтверждения, и подтверждение называет - каждую выбранную раздачу поимённо (оракул: тест — POST без признака - подтверждения отвечает отказом и не делает ни одного вызова `Delete` у - подставного воркера; ответ подтверждения содержит заголовки всех выбранных). -- Отказ на одной загрузке не отменяет остальных, а результат называет - удалённые и отказавшие поимённо с причиной (оракул: тест, где второй `Delete` - возвращает ошибку — первая и третья удалены, страница результата называет - вторую и её причину). -- Групповой путь не расширяет прав поштучного: попытка удалить задачу в - состоянии, где кнопка недоступна, отклоняется с тем же отказом - (оракул: тест — `Delete` для `downloading` возвращает `ErrConflict`, страница - показывает отказ, остальные выбранные не затронуты). -- Поведение страницы записано дельта-спекой и проходит валидацию (оракул: - `openspec validate --strict` и `task gate`). - -## Рамки - -Необратимое действие: `deleteFiles=true` сносит файлы раздачи, гард последней -копии в `Delete` выключен сознательно. Ослаблять подтверждение ради удобства -пачки нельзя — оно остаётся обязательным и поимённым. Групповой `Dismiss` и -автоматическая чистка по сроку хранения (`db-retention-cleanup`) — не эта -задача. diff --git a/tasks/items/delete-checks-active-infohash-owner.md b/tasks/items/delete-checks-active-infohash-owner.md new file mode 100644 index 0000000..bf600d7 --- /dev/null +++ b/tasks/items/delete-checks-active-infohash-owner.md @@ -0,0 +1,66 @@ +# 🐞 Не сносить раздачу, которой владеет другая активная загрузка + +- **Тип:** fix +- **Категория:** Ядро продукта +- **Зачем:** удаление старой закрытой задачи уничтожает файлы живой загрузки с тем же инфохэшем; воспроизведено падающим тестом на ревью bulk-delete-page +- **Теги:** goal:state-integrity + +`Delete` зовёт `torrents/delete` с `deleteFiles=true` по хешам своей записи и не +спрашивает, не владеет ли этим инфохэшем другая **активная** загрузка. Человек +подтверждает удаление одной записи, а необратимое действие применяется к чужим +живым данным. + +Двигает у цели `state-integrity` строку завершения «ни один известный сегодня +путь не оставляет состояние, которое не объясняется историей переходов»: живая +загрузка B уходит в `failed` по пропаже источника, и по её записи не видно, что +файлы снесла чужая операция. + +## Воспроизведение + +1. Довести задачу A по инфохэшу H до `done`. +2. Добавить тот же релиз заново: `CreateDownloadIfNoActive` ищет владельца + только среди активных, A терминальна, дедупа нет — заводится активная задача + B по тому же H. Файлы на диске у A и B общие, qBittorrent дедуплицирует + раздачу по хешу. +3. Удалить A — со страницы загрузки, из Telegram или со страницы группового + удаления. + +Видно вместо ожидаемого: раздача снесена с файлами, B продолжает считать себя +качающейся и уходит в `failed` после дебаунса. Если B ещё не дошла до раскладки, +копии не остаётся нигде. + +Прогнанные оракулы ревью (отчёт — +`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`, находка +2): `TestAdversaryDeletablePageOffersHashOwnedByActiveDownload`, +`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload`. + +## Затрагивает + +- `Worker.Delete` (`internal/worker/review.go`) — вызов `qbt.Delete`; +- предикат `store.FindActiveByInfohash` — существующий, новый заводить не надо; +- спека `state-reconciliation`, требование «Полное удаление загрузки + пользователем» — сегодня оно про такую проверку молчит; +- строка выбора и подтверждения на странице группового удаления, если решено + показывать признак чужого владения; +- qBittorrent `torrents/delete` с `deleteFiles=true` — необратимая операция. + +## Критерии приёмки + +- Удаление задачи, чьим инфохэшем владеет активная загрузка, отклоняется + конфликтом и раздачу не сносит (оракул: тест `internal/worker` — задача A в + `done` и активная B по тому же хешу, `Delete(A)` возвращает `ErrConflict`, у + подставного клиента qBittorrent ноль вызовов `Delete`). +- Отказ виден человеку причиной, а не общим «внутренняя ошибка» (оракул: тест + `internal/httpapi` — строка отчёта называет, что инфохэшем владеет активная + задача). +- Удаление задачи, чей инфохэш никем больше не занят, работает как прежде + (оракул: существующие тесты `Delete` остаются зелёными). +- Поведение записано дельта-спекой `state-reconciliation` (оракул: + `openspec validate --strict` и `task gate`). + +## Рамки + +Необратимое: снос раздачи с файлами. Проверка ставится в ядре, а не в +транспорте — допуск обязан держаться без транспорта. Правило дедупа при приёме +(«владелец ищется среди активных») эта задача не меняет: связывание v1/v2 и +доверие к паре `xt` разбирает `infohash-identity-integrity`. diff --git a/tasks/items/delete-holds-worker-lock.md b/tasks/items/delete-holds-worker-lock.md new file mode 100644 index 0000000..01daefe --- /dev/null +++ b/tasks/items/delete-holds-worker-lock.md @@ -0,0 +1,59 @@ +# 🐞 Не держать общий замок воркера во время обращения к qBittorrent при удалении + +- **Тип:** fix +- **Категория:** Инфраструктура +- **Зачем:** на время удаления встаёт весь фон: опрос, сверка и команды остальных транспортов ждут замок до 30 с на загрузку, пачкой — минутами; замерено на ревью bulk-delete-page + +`Worker.Delete` берёт `w.mu` и держит его через сетевой вызов `qbt.Delete`, хотя +правило пакета обратное и записано рядом: медленные вызовы идут **вне** замка +(`internal/worker/worker.go`, `Poll` зовёт `qbt.Torrents` до `Lock`). Замок при +этом один на весь воркер, а не на задачу — комментарий пакета и +`docs/architecture.md` называют его per-download, и это тоже неверно. + +## Воспроизведение + +1. Заставить qBittorrent отвечать на `torrents/delete` медленно (подставной + клиент с задержкой; в жизни — снос раздачи с большим числом файлов на + загруженном диске). +2. Во время вызова дёрнуть через тот же воркер любую команду по **другой** + загрузке. + +Видно вместо ожидаемого: команда ждёт весь чужой сетевой вызов. Замер ревью: при +задержке `qbt.Delete` 300 мс конкурентный `Cancel` по неизвестному +идентификатору заблокирован на `280.450631ms`. Таймаут клиента qBittorrent — +зашитые 30 с (поля в `[qbittorrent]` нет), поэтому верхняя граница на одну +загрузку — 30 с, а на пачку группового удаления — минуты, в течение которых не +идут ни опрос, ни сверка, ни уведомления. + +Отчёт ревью с оракулами — +`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`, +находка 3. + +## Затрагивает + +- `Worker.Delete` (`internal/worker/review.go`) — границы критической секции; +- `w.mu` и комментарий пакета `internal/worker` — «per-download» неверно; +- `docs/architecture.md`, строка «Переходы состояний» в единых точках — тот же + неверный факт; +- потолок времени на проход группового удаления (`httpapi.bulkBudget`) и + требование о нём в спеке `web-ui` — при сужении замка они теряют основание; +- поле таймаута в секции `[qbittorrent]` конфига, которого сегодня нет. + +## Критерии приёмки + +- Команда по одной загрузке не ждёт сетевого вызова по другой (оракул: тест + `internal/worker` с подставным клиентом, блокирующимся внутри `Delete`, — + `w.mu.TryLock()` из теста возвращает `true`). +- Состояние после сетевого вызова перечитывается, и удаление не переходит в + `deleted`, если задача успела уйти из допустимого состояния (оракул: тест на + смену состояния между шагами). +- Факт про замок исправлен в комментарии пакета и в `docs/architecture.md` + (оракул: `task gate`, шаг `canon`). +- Решено и записано, остаётся ли потолок времени на проход группового удаления + (оракул: дельта-спека и `openspec validate --strict`). + +## Рамки + +Трогает операцию, которая сносит файлы: разбор идемпотентности при повторе +обязателен. Уборка собственного торрента после отмены (`processCatched`) — уже +устроена нужным образом и служит образцом, менять её не надо. diff --git a/tasks/items/delete-leaves-stale-done.md b/tasks/items/delete-leaves-stale-done.md new file mode 100644 index 0000000..7389837 --- /dev/null +++ b/tasks/items/delete-leaves-stale-done.md @@ -0,0 +1,63 @@ +# 🐞 Не оставлять задачу в done, когда ссылки сняты, а раздача не снесена + +- **Тип:** fix +- **Категория:** Ядро продукта +- **Зачем:** при недоступном qBittorrent задача весь простой соседа показывает done, хотя тайтла в Jellyfin уже нет: сверка падает на первом шаге и до коррекции не доходит +- **Теги:** goal:state-integrity + +`Delete` снимает библиотечные ссылки раньше, чем зовёт `qbt.Delete`. Если сосед +недоступен, локальный шаг проходит, внешний падает, и задача остаётся в `done`. +Спека рассчитывает, что расхождение живёт «до тика сверки», но `Poll` +возвращается на первой же ошибке `qbt.Torrents` и до `reconcileDesync` не +доходит вовсе — значит, расхождение живёт весь простой qBittorrent. + +Двигает у цели `state-integrity` ту же строку завершения — «ни один известный +сегодня путь не оставляет состояние, которое не объясняется историей +переходов»: `done` держится сколь угодно долго и историей переходов не +объясняется. + +## Воспроизведение + +1. Довести задачу до `done` с существующими библиотечными ссылками. +2. Погасить qBittorrent (в тесте — клиент на несуществующий адрес). +3. Выполнить удаление — поштучно или пачкой. + +Видно вместо ожидаемого: ссылки сняты, строки `file_link` удалены, раздача цела, +место не освобождено, а карточка, список и Telegram показывают `done`. Пока +qBittorrent лежит, ни один тик сверки этого не исправит. Замер ревью: отказ +приходит за `524.545µs`, то есть пачка успевает пройти по всем выбранным раньше, +чем человек поймёт, что сосед недоступен. + +Отчёт ревью — +`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`, +находка 4. + +## Затрагивает + +- `Worker.Delete` (`internal/worker/review.go`) — порядок шагов и переход после + отказа внешнего шага; +- `Worker.Poll` (`internal/worker/worker.go`) — ранний возврат по отказу + `qbt.Torrents` перед `reconcileDesync`; +- спека `state-reconciliation`, требование «Полное удаление загрузки + пользователем» — формулировка «кратковременное рассогласование до тика сверки + ожидаемо» сегодня неверна; +- граф переходов: `done → target_missing` уже легален, нового состояния не надо. + +## Критерии приёмки + +- После отказа внешнего шага при снятых ссылках задача не остаётся в `done` + (оракул: тест `internal/worker` — клиент qBittorrent отказывает, задача + оказывается в `target_missing`, а не в `done`). +- В `deleted` при этом задача не переходит: место не освобождено (оракул: тот же + тест). +- Повторное удаление после подъёма соседа идемпотентно дожимает раздачу (оракул: + тест — второй вызов при живом клиенте уводит задачу в `deleted`). +- Формулировка спеки приведена к тому, что код делает (оракул: + `openspec validate --strict` и `task gate`). + +## Рамки + +Порядок шагов менять можно, но снос раздачи раньше снятия ссылок — отдельное +решение с ценой: оно убирает необратимый локальный шаг при мёртвом соседе, но +меняет поведение при отказе на втором шаге. Ранний возврат `Poll` по отказу +`qbt.Torrents` трогать осторожно: на нём стоит вся сверка.