ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add

- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
av
2026-08-06 18:20:12 +03:00
parent 52615e4e49
commit d081ef1d30
30 changed files with 2190 additions and 63 deletions
@@ -0,0 +1,142 @@
## 1. N1 — вырожденное имя не покидает разборщик
- [x] 1.1 В `internal/torrent/torrent.go` нормализовать `DisplayName` в `Parse`:
сравнение с `metainfo.NoName` даёт пустую строку; краевые пробелы и
разделители строк схлопываются тем же `oneLine`, что уже применяется к
комментарию. В доке `Info.DisplayName` сказать, что нормализация здесь и
ниже по потоку вырожденное значение не встречается.
- [x] 1.2 В `internal/ingest/ingest.go` убрать ветку `|| ref == "-"` из
фолбека `source_ref` и поправить комментарий (нормализовано разборщиком).
- [x] 1.3 Тесты в `internal/torrent`, три входа врозь: (а) `name` равно `-`
`DisplayName` пуст и `Context()` без строки названия; (б) `name`
отсутствует → `DisplayName` пуст (случай уже работал, закрепляем, чтобы не
путался с (а)); (в) имя с переводом строки → одна строка названия, число
строк-фактов как у обычного имени. Заодно исправить неверный комментарий
`TestContextEmptyWhenNoFields` («info без имени (NoName-сентинел)») — он и
есть источник исходной ошибки ревью.
- [x] 1.4 Тест в `internal/ingest`: `.torrent` с именем `-`, присланный файлом →
`source_ref` равен имени файла.
- [x] 1.5 Тест в `internal/worker`: для раздачи с именем `-` подсказка,
уходящая в `namer`, пуста (третий потребитель `DisplayName` — вход LLM;
`fakeNamer` дополнить полем `gotHint`).
## 2. N3 — контракт «на ошибке результат пуст»
- [x] 2.1 В `internal/ingest/ingest.go` объявить контракт в доке `Ingest` и
`Result` и удержать его **структурно**: именованный возврат + один
`defer`, обнуляющий результат при ненулевой ошибке.
- [x] 2.2 Тест в `internal/ingest`: на каждом классе отказа (невалидный
источник, сбой хранилища на дедуп-чеке, сбой хранилища на заведении)
результат равен нулевому значению **целиком**, а не только по полю
`DownloadID`.
- [x] 2.3 `internal/httpapi/httpapi.go`, REST `handleAPIAdd` — заменить
устаревший комментарий и передавать в `s.apiErr` пустой идентификатор.
- [x] 2.4 `internal/httpapi/httpapi.go`, веб-форма `handleUIAdd` — то же для
`userErr` (третий вызов приёма, в первой редакции задач пропущен).
- [x] 2.5 `internal/tgbot/bot.go` — то же для `opErr`; в комментарии назвать
честно, что корреляционного ключа у отказа приёма в Telegram нет.
## 3. N4 — корреляция записи о неуспешном добавлении
- [x] 3.1 В `internal/worker/worker.go` метод `Retry` присваивает
`ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())` сразу после
чтения загрузки; одноразовые `w.scoped(…)` и ручные `w.log.…` с
доклеенными `capability`/`download_id` внутри метода схлопнуть в
`logctx.From(ctx)`.
- [x] 3.2 Тест в `internal/qbt` с подставным сервером, отвечающим `Fails.`:
запись о вызове содержит поля из scoped-логгера `ctx` (`download_id`,
`infohash`) и `ext.operation = torrents/add`; **негативная половина**
значение отправленного magnet с узнаваемым `passkey` в записи не
встречается ни в одном поле, включая `error`.
- [x] 3.3 Тест в `internal/worker`: `Retry`, дошедший до `Add`, вызывает клиента
с `ctx`, несущим scoped-логгер загрузки (`fakeQbt` дополнить записью
переданного `ctx`).
## 4. N5 — записка о границах разбора `.torrent`
- [x] 4.1 Снять замер во временном каталоге (`tmp/`, после замера удаляется):
крафт-вход, объявляющий длинную bencode-строку, через `metainfo.Load`;
зафиксировать аллокацию и исход для нескольких объявленных длин, включая
значение выше потолка.
- [x] 4.2 Завести `docs/research/torrent-bencode-limits.md`: версия библиотеки,
файл:строка, команда замера, числа, вывод с условиями; наблюдение о
`BestName`/`NoName` (что библиотека сентинел не синтезирует); отметить,
что panic-гард `files()` не покрывает `Load`/`UnmarshalInfo`/`HashBytes`,
и назвать **условие устаревания** — перепроверить при обновлении
`anacrolix/torrent`.
- [x] 4.3 Добавить строку записки в индекс `docs/research/README.md` и
расширить его вводную: каталог держит и наблюдения о поведении наших
разборщиков и зависимостей на границе формата.
## 5. Отработка ревью изменения (чекпоинт 2, профиль `deep`)
- [x] 5.1 B1 — в `torrent.displayName` схлопывание идёт ДО сравнения с
`metainfo.NoName`; тест стал табличным (`-`, `" - "`, `"-\n"`, `"\t-"`,
NBSP+`-`); порядок закреплён в дельта-спеке.
- [x] 5.2 B2 — scoped-логгер до внешнего вызова добавлен ещё в шести командах
(`Relink`, `Rerecognize`, `Refine`, `ChooseCandidate`, `AddManualSource`,
`SetProviderID`); ложное «единственная команда» в `proposal.md`
исправлено.
- [x] 5.3 F1 — тест корреляции по `request_id` на обоих HTTP-транспортах
(REST и веб-форма).
- [x] 5.4 F2 — инъекция ошибок в `fakeStore` (`SetDownloadState`,
`SetRetriedAt`, `SetSourceMissCount`) и тесты best-effort веток `Retry`.
- [x] 5.5 F3 — в записку разведки вложен текст программы замера и добавлено
разграничение `TotalAlloc`/RSS с числами.
- [x] 5.6 F4 — в дельта-спеке исправлены два неточных утверждения (широта
схлопывания и обоснование исключения для путей файлов).
## 6. Проверка
- [x] 6.1 `task gate` зелёный (go-шаги отработали, не `SKIP`).
- [x] 6.2 `openspec validate --strict ingest-nits`.
## Приёмочные критерии
### Из постановки задачи
Копия из `docs/tasks/items/ingest-nits.md` — приходят снаружи, пайплайном не
сочиняются и не занижаются.
- Для безымянного торрента `Context()` не отдаёт «-» как название — поле пустое
(оракул: тест разбора на фикстуре безымянного торрента в `internal/torrent`).
**Замечание к критерию:** ревью предложения показало, что для торрента *без
имени* это верно и до изменения; отдаёт «-» торрент, который сам объявил
`name: "-"`. Проверяются оба входа врозь (задача 1.3).
- Комментарии в `httpapi` и `tgbot` описывают фактическое поведение `Ingest`:
на любом пути ошибки возвращается пустой `Result`, корреляция идёт по
`request_id` (оракул: чтение диффа на ревью — механического оракула нет).
- Лог неудачного добавления в qBittorrent несёт инфохэш для корреляции (оракул:
тест клиента с подставным сервером, проверяющий поля записи).
- Наблюдение про аллокации bencode до `MaxStrLen` записано в `docs/research/`
с провенансом либо явно отклонено строкой в теле задачи (оракул: `task gate`,
шаг канона).
### Из рубрики прохода `review-rubric`
Свойства, порождённые до чтения кода; взяты те, что изменение обязано
удовлетворить. Непокрытые названы явно.
- Нормализация вырожденного имени живёт **ровно в одной точке** — на границе
разбора; ни один потребитель `DisplayName` знания о `-` не содержит (оракул:
1.11.2 плюс `grep -rn 'NoName\|"-"' internal --glob '!internal/torrent/**'`
без попаданий по смыслу «имя раздачи»).
- Нормализация имени охватывает разделители строк и краевые пробелы, а не
только вырожденное значение (оракул: 1.3в).
- У **каждого** из трёх потребителей нормализованного поля свой оракул (оракул:
1.3, 1.4, 1.5 — три теста в трёх пакетах).
- Контракт «на ошибке результат нулевой» удерживается структурой, а не
перечнем известных путей (оракул: 2.1 — одно место обнуления; 2.2 — сравнение
с нулевым значением целиком).
- Публичная диагностика отказа приёма несёт корреляционный ключ, и ключ назван
поимённо для каждого транспорта (оракул: требование дельта-спеки; для Telegram
зафиксировано отсутствие ключа как сегодняшнее состояние — вопрос записан).
- Запись об отказе внешнего сервиса самодостаточна и **не несёт секретов**
(оракул: 3.2, обе половины).
- **Не покрыто и почему:** свойство «обязанность класть scoped-логгер проверена
механически перебором всех команд воркера» — перебор потребовал бы правки
`Cancel`/`Dismiss`, которую этот change сознательно не делает (открытый вопрос
дизайна); оракул остаётся точечным (3.3). Свойство «поле `infohash` записи
называет тот источник, с которым вызов ушёл» — не покрыто: сегодня значения
совпадают, расхождение возможно лишь у загрузки с несколькими хешами; идёт в
урожай.