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

27 KiB
Raw Blame History

Ревью кода: 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 исходят из тождества «orphanednlink<=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, TestAdversaryDeleteWipesSourceOfAnotherActiveDownloadDelete(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). Фраза пре-существующая; по решению человека дефект уходит в беклог отдельной задачей, а текст вливается в спеку как есть.