Files
jellybit/openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md
T
av 288be8ec34 web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
2026-08-10 17:43:36 +03:00

316 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ревью кода: `bulk-delete-page` — итог триажа (стадия `large`)
## Сводка
- **Размер / сложность / метка:** крупное / незнакомое / **large**. Метка
поднята с `medium` повторной разметкой после правок дельта-спек: изменение
трогает `store` + `worker` + `httpapi` + `tgbot` + шаблоны и заводит новую
точку входа поверх необратимой операции.
- **Режим прогона:** по графу.
- **Гейт:** зелёный после всех правок, diff-coverage 89 %.
- **Находок на входе:** 10 новых (`architecture` A1A3, `adversary` S1S4,
`ops` O1–O3) + 6 перенесённых из отчёта `medium`-стадии. **На выходе:** 6.
Прогон шёл в две стадии. Сперва `medium` (autotests, specs, code, basics,
triage) — его находки отработаны:
- отказ чтения хранилища отличён от отсутствия записи (строка «состояние
прочитать не удалось», лог, сценарий спеки);
- строки отчёта читаются неотменяемым контекстом;
- отсутствие записи явно названо в спеке не-системным отказом;
- три sentinel'а разбора пачки переехали в `classifyErr`, таблица
`docs/conventions/errors.md` дополнена;
- добавлен оракул на ссылку `/delete` в шапке;
- по решению человека введён потолок времени на проход (`bulkBudget`);
- по решению человека спека перестала обещать, что повтор отправки ничего не
делает.
### Тема без отчёта на этой стадии — находка о прогоне
**Три прохода `medium`-стадии (`autotests`, `specs`, `code`) после правок не
перезапускались, а правки затронули их предмет.**
- `specs` (тема **requirements**) отчитывался по **прежнему** тексту дельта-спек.
После него дельты изменены трижды. Соответствие кода изменённому тексту не
проверял никто.
- `code` (тема **conventions**) отчитывался до переезда sentinel'ов в
`classifyErr` и до правки `docs/conventions/errors.md`.
- `autotests` — гейт перепрогнан целиком и зелёный; вопрос темы «есть ли тест,
который упал бы без этой правки» на новых строках заново не задавался.
Это не «проходы не нашли», а «проходы не смотрели».
### План разметки с исходом по каждой теме
| тема | дом | глубина | закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/{web-ui,state-reconciliation}` + дельты | разбор | `specs` | отчёта на этой стадии нет; закрыта на `medium` (3 находки), текст дельт после этого изменён |
| autotests | `CLAUDE.md` → «Гейт» | — | `autotests` | отчёта на этой стадии нет; гейт перепрогнан, зелёный |
| conventions | `docs/conventions/` | разбор | `code` | отчёта на этой стадии нет; закрыта на `medium` (3 находки) |
| architecture | `docs/architecture.md` + источник `docs/passport.md` | доказательство | `architecture` | **закрыта**, 3 находки (A1A3) |
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 4 находки (S1S4) |
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта**, 3 находки (O1O3) |
| тема проекта | своих тем в `docs/` нет | — | — | дома у темы нет |
---
## Блокирует мердж
### 1. Строка `done` уезжает в пачку без отметки последней копии — единственная копия медиафайла стирается по подтверждению, которое об этом промолчало
- Файл: `internal/httpapi/bulkdelete.go:113,220`; причина —
`internal/worker/reconcile.go:28-41,56-67`, `internal/layout/layout.go:490-510`
- Severity: `major`; Confidence: high; проход `adversary` (S1)
- **Оракул** — два падающих теста (`go test -overlay`):
`TestAdversaryDoneRowIsLastCopyWithoutWarning`,
`TestAdversaryDoneRowConfirmedWithoutLastCopyWarning` («ни страница выбора, ни
подтверждение не сказали о последней копии… отчёт "Удалено — 1"»).
Механизм сверен триажем: `LastCopy: d.State == store.StateOrphaned`;
`sourceSeen` берётся исключительно из присутствия хеша в ответе
`torrents/info`, байты на диске сверка не щупает, поэтому
`deriveState(true, true) == done` держится сколько угодно долго;
`layout.Remove` не зовёт `isLastCopy` вовсе.
- Последствие: задача в `done`, у которой байты источника исчезли с диска, а
раздача осталась в списке qBittorrent (ручная уборка каталога, отвалившийся
том, торрент в `missingFiles`), предлагается к удалению без единственного
предохранителя, который дельта-спека для этого и заводит. `Undo` на том же
входе отказал бы целиком. Позиция №1 шкалы `docs/security.md`, инвариант
«последняя копия не снимается» — **необратимо**. Спека и решение D2 исходят из
тождества «`orphaned``nlink<=1`»; оно неверно в одну сторону.
- **Действие: развилка.** → **решение человека (2026-08-10): оставить как есть,
назвать границу в спеке.** Отметка выводится из состояния, случай «`done` с
пропавшими байтами источника» ею не покрыт, и это записано прямо в требовании
о поимённом подтверждении. Довод: поштучный путь такой отметки не несёт
вовсе — пачка не ухудшила положение, а улучшила его не до конца.
### 2. Подтверждённое удаление старой закрытой задачи сносит источник у другой, живой загрузки с тем же инфохэшем
- Файл: `internal/worker/review.go:665-669`; причина —
`internal/store/download.go:325-371`
- Severity: `major`; Confidence: high; проход `adversary` (S2)
- **Оракул** — два падающих теста:
`TestAdversaryDeletablePageOffersHashOwnedByActiveDownload`,
`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload``Delete(A)` снёс
раздачу с файлами, хотя тем же инфохэшем владеет активная загрузка B в
состоянии `downloading`»). `CreateDownloadIfNoActive` ищет владельца только
среди активных, поэтому терминальная A и активная B по одному хешу — легальное
состояние; `ListDeletableDownloads` фильтрует только по состоянию;
`qbt.Delete` вызывается без проверки активного владения, хотя предикат в
домене есть — `store.FindActiveByInfohash`.
- Последствие: человек подтверждает удаление одной сущности, необратимое
действие применяется к другой — класс из журнала `docs/review.md`
(2026-08-06). **Дефект предсуществует в поштучном `Delete`**; изменение его не
вносит, но снимает естественную остановку «человек открыл карточку и
посмотрел».
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
задачей.** Дефект целиком в поштучном `Delete`, изменение его не вносит;
чинить его здесь значит превратить задачу про новую страницу в переборку
удаления.
### 3. Пачка удалений морозит весь сервис до ~145 секунд, и предохранитель от этого стоит в транспорте, компенсируя свойство ядра
- Файл: `internal/httpapi/bulkdelete.go:30-39,156-167`; причина —
`internal/worker/review.go:619-621`
- Severity: `major`; Confidence: high; проходы `ops` (O1), `architecture` (A1) и
`basics` прошлой стадии — три раза одна причина
- **Оракул** — замер `ops` на настоящем `*worker.Worker`: при задержке
`qbt.Delete` 300 мс конкурентная `Cancel(unknown-id)` через тот же worker
заблокирована на `280.450631ms`. Плюс падающий тест прошлой стадии
`TestTriageDeleteHoldsWorkerLockAcrossQbt`. Сверено триажем:
`internal/worker/worker.go:9-10` и `docs/architecture.md` называют `w.mu`
**per-download**, фактически это один мьютекс на весь воркер; `worker.go:387-390`
— «Медленные вызовы идут ВНЕ `w.mu`»; таймаут клиента qBittorrent — зашитые
30 с, поля в конфиге нет. Отсюда арифметика: `bulkBudget` проверяется между
единицами, значит реальный потолок — 120 + до 30 секунд.
- Последствие: всё это время стоят `Poll` (→ сверка), `processCatched`,
уведомления и команды остальных транспортов. Плюс архитектурная цена: в
`httpapi` живут три доменных правила и понятие «невыполненный остаток»;
поштучный путь той же защиты не имеет; групповой `Dismiss` либо скопирует
~60 строк, либо тихо обойдётся без них.
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
задачей.** Свойство целиком пре-существующее (поштучное удаление морозит
сервис до 30 с той же причиной); бюджет времени остаётся как ограничение
сверху, сужение замка — отдельная работа со своей спекой.
---
## Стоит исправить сейчас
### 4. При недоступном qBittorrent задача остаётся `done`, хотя раскладки в Jellyfin уже нет, и сама не выправится до подъёма соседа
- Файл: `internal/worker/review.go:634-670`; причина —
`internal/worker/worker.go:635-639`
- Severity: `major`; Confidence: high; проход `ops` (O2)
- **Оракул** — замер: `down (unreachable) Delete: elapsed=524.545µs
err=connection refused`; `bulkFailThreshold=3` останавливает пачку за
миллисекунды (6 ERROR-строк, шторма нет). Сверено триажем: `Poll` начинается с
`w.qbt.Torrents(ctx, "")` и при ошибке возвращается на первой же строке,
поэтому `reconcileDesync` — а с ним коррекция `done → target_missing`, на
которую рассчитывают D9 и спека, — не вызывается ни разу, пока сосед лежит.
- Последствие: ссылки сняты и `file_link` удалены, раздача не снесена, место не
освобождено, состояние говорит `done`. Расхождение живёт весь простой соседа.
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
задачей.** Порядок шагов и ранний выход `Poll` — пре-существующие, поштучный
путь ведёт себя ровно так же.
### 5. Bidi-символы в имени раздачи уезжают в экран подтверждения дословно
- Файл: `internal/worker/discover.go:55`, `internal/httpapi/httpapi.go:701-709`
- Severity: `minor`; Confidence: high; проход `adversary` (S3)
- **Оракул** — падающий тест `TestAdversaryConfirmRendersBidiTitleVerbatim`:
U+202E уехал дословно и на `/delete`, и на `/ui/delete/confirm`.
- Последствие: поимённое чтение заголовков — единственный предохранитель
необратимой пачки; подменённый порядок символов делает его ненадёжным.
- **Действие: инлайн.** → **исправлено**: `displaySafe` снимает `unicode.Cf` и
управляющие на показе (не на записи), оракул добавлен.
### 6. `docs/security.md` объявляет несуществующий пробел
- Файл: `docs/security.md:106-107`
- Severity: `minor`; Confidence: high; проход `adversary` (наблюдение)
- **Оракул** — сверено по исходникам: `internal/llm/openai.go:23`
`maxResponseBody = 8 << 20`, `internal/metadata/http.go:41` — 4 MiB. Документ
утверждал «Лимита на размер ответа LLM нет — известный пробел».
- **Действие: инлайн.** → **исправлено**: оба лимита названы числами в
`docs/security.md`, вопрос темы в `docs/review.md` переформулирован.
---
## Гипотезы без доказательства
- **S4 (`adversary`, `major` → гипотеза): исходный путь раскладки не проверяется
на принадлежность песочнице.** Тест `TestAdversarySourceEscapesDownloadsSandbox`
разложил `…/config/config.toml` как `…/movies/Дюна (2021)/Дюна (2021).toml`
через `..` в имени файла раздачи. **Вне диффа целиком** и с недостающим
звеном, названным самим проходом: согласится ли qBittorrent отдать в
`/torrents/files` имя с `..` (libtorrent такие пути санитизирует). Уезжает в
урожай задачей.
- **O3 (`ops`, `minor` → гипотеза): страница `/delete` раздувается без
ретеншена.** Замер: 5000 строк → 2 241 604 байта, 29.7 мс. На обозримом
горизонте предела не надо; отсутствие пагинации заказано спекой дословно;
смежная причина стоит задачей `db-retention-cleanup`.
- **A2 (`architecture`, `minor` → снято): «третий способ выбрать загрузки по
набору состояний».** Перечень берётся из единой точки; `ListDownloadsByState`
делает `SELECT *` без `LEFT JOIN recognition` и с другим `ORDER BY` —
переиспользованию не подлежит без правки обоих вызывающих.
- **A3 (`architecture`, `minor` → promote): строка загрузки размножена по трём
шаблонам.** Норма говорит о паре «страница ↔ htmx-фрагмент одного
обработчика»; здесь три разные страницы с разной семантикой строки и без htmx.
Второй независимый провенанс повышает приоритет правила, `confidence` — нет.
---
## Promote candidates
- Партиал строки — один на все экраны многостраничной формы (`code`,
`architecture`, независимо).
- Таймаут клиента qBittorrent — из конфига, а не дефолт транспорта: в
`[qbittorrent]` поля нет, в `[llm]`, `[metadata.*]`, `[jellyfin]` — есть
(триаж).
- Комментарий пакета `worker` и `docs/architecture.md` называют `w.mu`
per-download блокировкой, а это единый мьютекс на весь воркер (`ops`).
- `store.State.CanDelete()` — в `docs/architecture.md` → «Единые точки проекта»
рядом с `IsObservable()` (`architecture`).
- Правило именования маршрутов веб-UI: глагол-первым `/delete` против семьи
`/downloads/{id}/…` (`architecture`).
- Дубль построения `IN (?,…)` в `store` — хелпер при третьем появлении
(`architecture`).
- Отвязка необратимой операции от контекста запроса — правило, а не приём:
групповой путь делает `context.WithoutCancel`, поштучный нет (`code`).
- Отказы `store` не проверяются ни одним тестом — как класс (`autotests`).
---
## Границы покрытия
**Метка `large`, режим по графу.** Запущено на этой стадии: `architecture`,
`adversary`, `ops` — все на глубине «доказательство», все вернули отчёт.
**Что не запускалось и почему:** `autotests`, `specs`, `code` — сознательно не
перезапускались после правок `medium`-стадии; цена названа первой строкой
сводки. `basics` — не запускался по плану: все шесть тем ядра разобраны
именными проходами, поэтому возражений о метке от корректора прийти не могло.
**Чего запущенные проходы не могли проверить в принципе:** `architecture` не
судит корректность кода и не строит путей отказа; `adversary` показывает
достижимость, но не частоту; `ops` меряет на синтетическом входе своего
прогона, а не на рабочем потоке.
**Потолки проходов.** Ни один из трёх проходов не сообщил своего потолка.
Молчание неотличимо от «срезать было нечего» — это находка о прогоне. Триажу
пришли сжатые пересказы выводов, а не дословные блоки `Coverage of this pass`.
Потолком триажа не срезано ничего: на входе 10 новых находок, в первые две
секции ушло 6, остальные названы поимённо.
**Что остаётся целиком на человеке:** история инцидентов на umbar; поведение
SQLite под реальным объёмом и профилем нагрузки; завязка внешних потребителей на
новые маршруты; суждение «этой функциональности не должно существовать»;
качество распознавания.
**Перестали проверять сознательно:** идиоматичность Go (проход `idiom`
упразднён 2026-08-04); на метках `small`/`medium` не проверяется ничто,
требующее запуска — здесь метка `large`, и именно поэтому появились находки 1–4;
**на этом прогоне добавилось третье, разовое** — темы `requirements`,
`conventions`, `autotests` не перепроверялись после правок.
**Каких документов не хватило:** `docs/security.md` содержал устаревший факт
(находка 6, исправлено); `docs/architecture.md` и комментарий пакета `worker`
описывают `w.mu` неверно — проходы рассуждали против описания, а не против кода,
и это пришлось сверять триажу. `docs/review.md` → «Типовые ложноположительные» и
журнал дефектов есть и применены.
**Чего в конвейере нет вовсе:** решения проекта (`docs/adr/`) не сверялись —
процессный документ, расхождение ловит сверка документации; записанные
наблюдения (`docs/research/`) не использовались — всякое число снято на этом
прогоне; поимённая сверка с руководствами по стилю языка не задавалась ни одним
проходом; альтернативной реализации, с которой можно сдиффить решения, у
конвейера нет.
**Формулировка «критичных проблем не обнаружено» к этому отчёту не применима:**
проверено ровно то, что перечислено выше, и не проверено ровно то, что
перечислено выше.
---
## Досверка `specs` после правок (закрывает названную выше дыру прогона)
Проход `requirements` перезапущен на изменённом тексте дельт и изменённом коде —
дыра «соответствие кода изменённому тексту спеки не проверял никто» закрыта.
Покрытие: все 6 требований обеих дельт по под-пунктам, `openspec validate
--strict` — valid, тесты затронутых пакетов зелёные.
**Д1 (major, high). Чистка заголовка задела все заголовки веб-UI и снимала
лишнее.** `internal/httpapi/httpapi.go`. Оракул — прогон копии функции:
`"👨‍👩‍👧 family"` → `"👨👩👧 family"`, `"shah\u200Cname"` → `"shahname"`,
`"Duna\nchast 2"` → `"Dunachast 2"`. `unicode.Cf` — это не только
bidi-override, но и ZWJ/ZWNJ; поиск по списку идёт по сохранённому имени, и
скопированное с экрана название своей же записи не нашло бы. Правило нигде не
записано и применено непоследовательно (третья ветка заголовка чистку не
проходила). → **исправлено**: снимается только `unicode.Bidi_Control`,
управляющие заменяются пробелом, чистка применяется ко всем трём веткам, и
заведено MODIFIED-требование «Заголовок загрузки из имени раздачи» со сценарием.
Тест расширен проверкой, что составные эмодзи остаются целыми.
**Д2 (minor, high). Потерянный ответ пачки не восстанавливался по журналу.**
Требование «Исход каждой единицы SHALL попадать в журнал» держалось на воркере,
который отказы по конфликту и отсутствию записи пишет на `DEBUG`, а не начатые
единицы не пишет вовсе; итоговая строка несла только счётчики. → **исправлено**:
транспорт пишет строку `bulk delete item` на `INFO` по каждой единице с исходом
`deleted|failed|skipped` и причиной отказа.
**Д3 (minor, high). Отказ разбора тела формы диагностировался как
«некорректный идентификатор», исходная ошибка не доезжала ни до человека, ни до
лога.** → **исправлено**: отдельный sentinel с своим текстом, исходная ошибка
уходит в приватный канал.
**Поведение вне спеки, снятое заодно:** признак подтверждения и пачка
принимались и из строки запроса (`r.Form`), тогда как требование говорит «в
форме» — читаем только тело (`r.PostForm`).
**Осталось открытым и названо:** текст MODIFIED-требования
`state-reconciliation` обещает, что повторное удаление опирается на «приведённый
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
сверки ожидаемо)». При лежащем qBittorrent это неверно — сверка не доходит до
коррекции (находка 4). Фраза пре-существующая; по решению человека дефект уходит
в беклог отдельной задачей, а текст вливается в спеку как есть.