- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
316 lines
27 KiB
Markdown
316 lines
27 KiB
Markdown
# Ревью кода: `bulk-delete-page` — итог триажа (стадия `large`)
|
||
|
||
## Сводка
|
||
|
||
- **Размер / сложность / метка:** крупное / незнакомое / **large**. Метка
|
||
поднята с `medium` повторной разметкой после правок дельта-спек: изменение
|
||
трогает `store` + `worker` + `httpapi` + `tgbot` + шаблоны и заводит новую
|
||
точку входа поверх необратимой операции.
|
||
- **Режим прогона:** по графу.
|
||
- **Гейт:** зелёный после всех правок, diff-coverage 89 %.
|
||
- **Находок на входе:** 10 новых (`architecture` A1–A3, `adversary` S1–S4,
|
||
`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 находки (A1–A3) |
|
||
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 4 находки (S1–S4) |
|
||
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта**, 3 находки (O1–O3) |
|
||
| тема проекта | своих тем в `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). Фраза пре-существующая; по решению человека дефект уходит
|
||
в беклог отдельной задачей, а текст вливается в спеку как есть.
|