хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
@@ -0,0 +1,215 @@
# Отчёт ревью — change `pocketbase-storage`
Метка `large`, режим «по графу». Составил проход `review-triage`; файл записал
оркестратор — среда запрещает подагенту писать отчётные `.md`. Раздел «Исход по
находкам» дописан оркестратором после отработки и помечен как таковой.
## Сводка
- **Размер:** крупное. **Сложность:** незнакомое. **Метка:** `large` — максимум
по обеим осям. Триггеры `docs/review.md`: «замена хранилища или переход на
PocketBase — любой её кусок», «смена модели очереди», «изменение, трогающее оба
входа сразу».
- **Гейт:** зелёный, проверен триажем — `task gate`, exit 0, восемь шагов.
- **Особенность прогона:** часть находок первой волны оркестратор починил до
запуска `ops`. Каждую пометку «ПОЧИНЕНО» триаж проверял в коде.
### Сигнал о заниженной метке
Не пришёл. `review-code` возражений не заявил; `review-basics` на этой метке не
запускался. Это одно молчание и одно отсутствие, а не подтверждение двумя
источниками.
### План разметки задачи с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельты | разбор | `specs` | **закрыта**, 6 находок |
| autotests | `CLAUDE.md`, «Гейт», «Инварианты» | — | `autotests` | **закрыта**, 3 находки |
| conventions | `docs/conventions/` | разбор | `code` | **закрыта**, 9 находок + 3 за срезом потолка |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | **закрыта**, 7 находок |
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 5 находок |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | **закрыта**, 3 находки + 3 замера |
**Темы без отчёта нет.** `basics` не запускался по условию состава при метке
`large` — темы он не уносил.
Отдельно: результатов **ревью дизайна** на вход триажа не подавали. Сошлись ли
ответы автора о трёх формах решения с построенным — на этом прогоне не проверено
никем.
### Счёт находок
36 пунктов от шести проходов → 27 причин после дедупликации → 12 починено до
`ops` (одна частично), 1 снята как неверная, 14 осталось плюс 1 новая, вскрытая
проверкой починок. В отчёте: 3 блокирующих, 3 «исправить сейчас», 6 гипотез,
3 promote.
### Проверка починок на месте
| находка | чем проверено | вердикт |
|---|---|---|
| `MaxSize: 0` = 5 МиБ (specs-1 = code-1 = adversary-2) | `entity.MaxRecordSize = 8 << 30` у поля файла и у тела приёма; тест судит границу `5<<20 + 1` и способен упасть | полна |
| хук панели правил записи конвейера (specs-2 = code-2 = adversary-3 = architecture-1) | `OnRecordUpdateRequest`; два теста, включая обратный | полна в объявленном объёме; остаток — блокер 2 |
| 413 на 32 МиБ и `ReadTimeout` 5 минут | `apis.BodyLimit(entity.MaxRecordSize)` на маршруте, `ReadTimeout = 0` | полна |
| гонка `srv` | буферизованный канал, чтение `select`/`default` | полна |
| `file` без `Required` | `Required: true`, шаг схемы на сервер не уезжал | полна |
| спека утверждала про суффикс имени | прогон: имя в хранилище — ровно заданное, суффикса нет | полна |
| рабочая копия без расширения | `Stage(ext string, …)`, приём передаёт расширение | полна |
| инвариант «колонка в N местах» | возвращён в `CLAUDE.md` с перечнем мест | полна |
| две записи ERROR на один отказ приёма | лог из обработчика снят | полна |
| расхождения с `conventions/database.md` | обе строки на месте | полна |
| **вечная ссылка на чужое аудио через журнал** | прогон: `Open` и `Localize` отдают текст с идентификатором записи, имени файла в нём нет | путь А закрыт; путь Б закрыт наполовину — см. «Стоит исправить», п. 2 |
| пустой держатель в `Save` | комментарий исправлен, ветка оставлена | частично, и этого достаточно |
**Снято как неверное:** замечание `architecture` про ключ объекта в Object
Storage. Оно стояло на посылке о суффиксе имени, которой нет: имя в хранилище и
есть `<uuid><расширение>`, значит утверждение `architecture.md` верно.
## Блокирует мердж
### 1. Образ не собирается: сборщик `golang:1.24`, а модуль требует Go 1.25
- Файл: `Dockerfile:2`, `go.mod:3`
- Severity: major, Confidence: high
- Оракул: сборка в контейнере `golang:1.24-alpine` → `go.mod requires go >= 1.25.0
(running go 1.24.13; GOTOOLCHAIN=local)`; на `golang:1.25-alpine` → успех.
Директива не наша: `pocketbase@v0.39.10/go.mod` объявляет `go 1.25.0`.
- Последствие: `task image` падает — выкладки change нет вовсе. Гейт этого не
показывает: образ он не собирает, а `go build` идёт на хостовом Go.
- Найдено: **никем** — вскрылось проверкой починок (триаж).
- Действие: инлайн
### 2. Правка задачи в панели пропадает при ближайшем сохранении шага
- Файл: `transcript_job_repo.go`, `job_mapping.go`, `panel.go`
- Severity: major, Confidence: high
- Оракул: временный тест — задача с `tg_chat_id = 111` захвачена шагом, правится
через `PATCH /api/collections/transcribe_jobs/records/<id>`, затем шаг
сохраняет снимок. Итог: `expected 999999 / actual 111`.
- Последствие: `Save` сторожил только `acquisition_id`, а запись клала **все**
поля снимком с момента захвата. Окно — до восьми часов. Ни строки в журнале,
ни отказа в панели. Панель ради правки и покупалась.
- Найдено: `ops`.
- Действие: развилка
### 3. Два из трёх шагов конвейера переписаны и не выполнены ни одним тестом
- Файл: `internal/service/transcribe.go` — `transcribeJob`, `checkTranscribeJob`,
`completeJob`
- Severity: major, Confidence: high
- Оракул: покрытие с `-coverpkg=./...` — 0.0% у всех трёх и у обоих
`FindAndRun*`; `git diff --stat` по файлу — 268 вставок, 192 удаления.
- Последствие: путь «отдать запись на распознавание → дождаться операции →
ответить пользователю» прошёл замену хранилища без единого выполнения.
- Найдено: `autotests`.
- Действие: инлайн
## Стоит исправить сейчас
### 1. Документы обещают, что рабочую копию убирает хранилище; убирает вызывающий
- Severity: minor, Confidence: high. Найдено: `architecture`. Действие: развилка.
- `architecture.md`, `design.md` и дельта-спека `storage` требуют, чтобы уборку
делало хранилище. В коде копию закрывает вызывающий тремя `defer`. Дефекта в
коде нет — расхождение в записи, которая уедет в архив.
### 2. Отказ заливки в Object Storage больше не отличим от любого другого
- Severity: minor, Confidence: high. Найдено: **никем** — вскрылось проверкой
полноты починки. Действие: инлайн.
- Починка приватности оборвала цепочку `%w` целиком: отозванные ключи,
исчезнувший бакет и отказ сети дают одну строку.
### 3. Входящие HTTP-запросы исчезли из журнала процесса
- Severity: minor, Confidence: high. Найдено: `code`. Действие: развилка.
- `sloggin` ушёл вместе с gin; `activityLogger` PocketBase пишет в свою таблицу.
Строка «*Расхождение:*» в `logging.md` указывает на удалённую библиотеку.
## Гипотезы без доказательства
1. Ответ SpeechKit об отказе операции может нести S3-URI и уехать в журнал и в
`error_text`. Оракул требует настоящего отказа Yandex — запрещено `CLAUDE.md`.
2. Имя файла в хранилище принимает почти любой хвост имени отправителя. Выхода за
каталог **нет** (15 враждебных имён). Станет находкой с появлением экрана
списка записей.
3. `entity.File.FileName` несёт два смысла — имя в хранилище и ключ объекта.
Сегодня они совпадают дословно.
4. Отмена контекста структурно невозможна: `RunInTransaction`/`FindRecordById`
контекста не принимают. Замер: под внешней блокировкой захват держится 9.53 с
при `busy_timeout=10000`. Уточнение уже объявленного долга.
5. Таймаутов у Telegram, S3 и SpeechKit по-прежнему нет. Задача заведена.
6. Приём пишет тело на диск дважды. Не замерено; нагрузка объявлена вне модели.
Ниже потолка названы и не потеряны: `LostAcquisitionError` типом там, где
конвенция просит sentinel; форма обёрток `"failed to <действие>: %w"`; уровень
`ERROR` с инкрементом метрики на потерянном захвате против `WARN` конвенции;
панель как третий вход в таблицу задач.
## Promote candidates
1. **`govulncheck ./...` шагом гейта.** Сегодня его нет ни в гейте, ни в списке
«чего в гейте намеренно нет». Достижимых уязвимостей две, унаследованных:
`GO-2026-6061` (grpc), `GO-2026-5764` (aws eventstream, s3).
2. **Сверка версии Go в `Dockerfile` с директивой `go` в `go.mod`.** Блокер 1
прошёл весь конвейер молча именно потому, что образ никто не собирает.
3. **Покрытие изменённых строк.** Блокер 3 — второй случай подряд, когда его
ловят руками.
## Границы покрытия
- Запускались: `specs`, `code`, `architecture`, `adversary`, `autotests` — по
коду **до** починок первой волны; `ops` — по коду **после**. Находки пяти
первых перепроверены триажем поимённо.
- `basics` не запускался: при метке `large` темы разобраны именными проходами,
своих тем проекта нет.
- Независимой реализации для сравнения не строил никто — прохода нет в конвейере.
- Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.
Для change, который впервые в проекте пишет на PocketBase, это самый дорогой
пробел.
- **Блоки `Coverage of this pass` до триажа дошли не от всех проходов.** О своих
потолках сообщил только `code` (конвенционная половина, 4, за срезом три
находки — перенесены). Остальные не сообщили; это находка о прогоне.
- Решения проекта (`docs/adr/`) и записанные наблюдения (`docs/research/`) прогон
не открывает — процессные документы. Расхождение change с записанным решением
ловит не ревью, а сверка документации (`av-dev-docs:healthcheck`).
- Не проверит ни один проход: поведение SpeechKit и Object Storage под нагрузкой
и на границах; реальный профиль нагрузки; стойкость `ffmpeg` к вредоносному
входу.
- Перестали проверять сознательно: разбор вывода настоящего `ffprobe` — решение и
цена в `ADR-2026-08-11-stub-adapters-in-tests.md`.
- Каких документов не хватило: `docs/conventions/web-ui.md` описывает будущую
SPA, а не панель — судить панель против конвенции было нечем; `docs/research/`
по весу шестичасовой записи пуст, оценки читались как оценки.
## Исход по находкам
*Раздел дописан оркестратором после отработки урожая; в отчёте триажа его нет.*
**Починено:**
- блокер 1 — `Dockerfile` переведён на `golang:1.25-alpine`, строки про Go 1.24 в
`CLAUDE.md` и `README.md` исправлены; сборка в контейнере проверена;
- блокер 2 — запись шага разделена: `applyOwnedByPipeline` кладёт только поля
конвейера, `applyToRecord` целиком остаётся заведению. Заведён тест
`TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob`; норма дописана в дельта-спеку
`pipeline` со сценарием;
- блокер 3 — заведён управляемый двойник распознавателя и восемь проверок
(`internal/service/recognition_test.go`). Покрытие: `transcribeJob` 0 → 67.6%,
`checkTranscribeJob` 0 → 72.4%, `completeJob` 0 → 100%, оба `FindAndRun*` → 100%;
- «стоит исправить» 2 — класс отказа SDK сохраняется через `smithy.APIError`,
адрес объекта — нет;
- «стоит исправить» 3 — журнал входящих запросов вернулся своим слоем в `main.go`
с пятью полями конвенции, `/health` и `/metrics` на `DEBUG`; расхождение в
`logging.md` снято.
**Решено развилкой, вариант назван человеку на чекпоинте:**
- «стоит исправить» 1 — норму привели к коду: хранилище даёт единственный способ
убрать копию, зовёт его шаг. Замкнуть уборку на репозиторий мешает конвертация:
ей нужны две копии разом. Цена названа — норма держится проверками, и проверки
заведены на приём и на шаг конвертации.
**Оставлено, передано урожаем:** все шесть гипотез и четыре находки ниже потолка;
три кандидата в promote.