учёт: закрыт переезд хранилища, заведён урожай его ревью
- storage-without-pocketbase закрыта как реализованная: приёмка сошлась по всем
пяти критериям записи и по двенадцати приёмочным свойствам рубрики ревью
дизайна, работа лежит коммитом c9b7765
- урожай триажа ревью развёрнут в одиннадцать записей с тегом партии
review-2026-08-23 и расставлен по зависимости, а не в конец списка
- находка про признак живости воркера слита в stalled-pipeline-metric: у неё та
же причина — вставший конвейер неотличим от простоя
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# 🐞 Не закрывать базу, пока живёт брошенный воркер
|
||||
|
||||
- **Тип:** fix
|
||||
- **Категория:** Очередь — Остановка чинится раньше бюджета остановки: пока база закрывается под живым воркером, замерять мягкий бюджет нечем — процесс падает паникой, а захват стоит до восьми часов
|
||||
- **Зачем:** По истечении жёсткого таймаута остановки процесс возвращается из run(), отложенный db.Close() обнуляет пулы, брошенный воркер разыменует nil и роняет процесс паникой; захват записи после этого стоит до восьми часов, и следа не остаётся.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Остановка сервиса не дожидается брошенных горутин. По истечении
|
||||
`ForceShutdownTimeout` `run()` возвращается, отложенный `db.Close()` обнуляет
|
||||
пулы соединений, а воркер, который не успел закончить шаг, разыменует `nil` и
|
||||
роняет процесс паникой.
|
||||
|
||||
Замер прогона триажа: `Close()` вернулся за 2.8 мкс, пока другая горутина
|
||||
держала пишущую транзакцию, и та зафиксировала её уже после закрытия.
|
||||
|
||||
Цена не в самой панике, а в следе: захваченная запись остаётся захваченной до
|
||||
истечения срока захвата — до восьми часов, — и о причине в журнале нет ничего.
|
||||
Инвариант «принятая запись не теряется молча» держится тем, что отказ виден
|
||||
владельцу записи; здесь не виден никому.
|
||||
|
||||
Нашли проходы `review-code` (C2) и `review-ops` (O3) ревью change
|
||||
`2026-08-23-storage-without-pocketbase`.
|
||||
|
||||
## Воспроизведение
|
||||
|
||||
1. Занять шаг конвейера работой дольше `[server] force_shutdown_timeout`.
|
||||
2. Послать процессу `SIGTERM`.
|
||||
3. По истечении жёсткого таймаута процесс возвращается из `run()` и закрывает
|
||||
базу; брошенный воркер обращается к закрытому пулу и роняет процесс паникой.
|
||||
4. Запись остаётся с признаком захвата, а в журнале об этом ни строки.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- порядок остановки в `cmd/transcriber/main.go` — возврат из `run()` и
|
||||
отложенное закрытие базы;
|
||||
- `internal/adapter/repo/sqlite/db.go` — `Close()` и жизненный цикл обоих пулов;
|
||||
- цикл воркера `internal/controller/worker` — что делает шаг, у которого база
|
||||
закрылась под руками;
|
||||
- ключи конфига `[server] shutdown_timeout` и `force_shutdown_timeout`;
|
||||
- поведение при остановке в спеке `pipeline`.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Жёсткая остановка не роняет процесс паникой. **Оракул:** тест — шаг спит
|
||||
дольше жёсткого таймаута, процесс останавливается, `recover` в тесте не
|
||||
срабатывает, код возврата нулевой.
|
||||
- Обращение к закрытой базе отвечает отказом, а не паникой. **Оракул:** тест
|
||||
репозитория — вызов после `Close()` возвращает ошибку, и она отличима от
|
||||
«записи нет».
|
||||
- О брошенной работе остаётся след. **Оракул:** тест с перехваченным журналом —
|
||||
жёсткая остановка при незакрытом шаге пишет строку с идентификатором записи и
|
||||
причиной.
|
||||
|
||||
## Рамки
|
||||
|
||||
Бюджет мягкой остановки этой задачей не замеряется — это задача
|
||||
`context-cancel-in-pipeline`; здесь закрывается только путь после жёсткого
|
||||
таймаута. Сроки захвата и рубежи не двигаются.
|
||||
@@ -0,0 +1,39 @@
|
||||
# 🧹 Вынести тело подкоманды resume из main-пакета и покрыть тестом
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Тот же пакет оснастки: после того как в cmd/devtools осядет подкоманда подъёма, тела подкоманд выносятся из main-пакета разом
|
||||
- **Зачем:** go test ./cmd/... отвечает no test files: подкоманда возврата записи в работу не проверена ничем, а тест на неё пришлось бы писать, повторяя её тело руками.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
`go test ./cmd/...` отвечает `no test files`. Подкомандой `resume` владелец
|
||||
возвращает остановленную запись в работу, пока этого не делают экраны, — и
|
||||
проверяет её только человек руками.
|
||||
|
||||
Тело подкоманды живёт в `main`-пакете и завязано на `log.Fatalf`: тест на неё
|
||||
пришлось бы писать, повторяя это тело у себя, — то есть проверять копию, а не
|
||||
предмет. Починка — вынести тело в вызываемую функцию, возвращающую ошибку.
|
||||
|
||||
Нашёл проход `review-specs` (S4) ревью change
|
||||
`2026-08-23-storage-without-pocketbase`.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- `cmd/devtools/resume.go` — тело подкоманды и его выход через `log.Fatalf`;
|
||||
- `cmd/devtools/main.go` — разбор доводов и выбор подкоманды;
|
||||
- сборка тестов `cmd/devtools`, которой сегодня нет.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Тело подкоманды вызывается из теста. **Оракул:** `go test ./cmd/...` больше
|
||||
не отвечает `no test files`, и тест зовёт функцию, а не повторяет её шаги.
|
||||
- Остановленная запись возвращается в работу. **Оракул:** тест на временной базе
|
||||
— после вызова у записи снят признак остановки, счётчик отказов обнулён, а в
|
||||
журнале событий записи стоит строка о возврате.
|
||||
- Отказ виден кодом возврата. **Оракул:** тест — несуществующий идентификатор
|
||||
даёт ошибку из функции, а команда — ненулевой код.
|
||||
|
||||
## Рамки
|
||||
|
||||
Подкоманду не переименовываем и доводов ей не добавляем: правится место тела, а
|
||||
не её договор с человеком. Боевой каталог данных не трогать — тест идёт по
|
||||
`t.TempDir()`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 🔬 Сверка файла с записью при отдаче
|
||||
|
||||
- **Тип:** research
|
||||
- **Категория:** Очередь — Ответ нужен до удаления записи и до нарезки на фрагменты: обе задачи правят обе стороны связи файла с записью
|
||||
- **Зачем:** Владение судится у записи, а файл открывается по её ссылке без сверки files.record_id и files.owner_id; схема этой связи не держит, и обе стороны будут править delete-record и long-audio-chunking.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Владение судится у записи: обработчик находит запись по идентификатору, сверяет
|
||||
владельца и открывает файл по ссылке из этой записи. Сверки `files.record_id` и
|
||||
`files.owner_id` при этом нет, и схема такой связи не держит намеренно.
|
||||
|
||||
Пути наружу сегодня нет — ссылку в запись кладёт только сервис, — и потому это
|
||||
разведка, а не починка. Но обе стороны связи будут править `delete-record` и
|
||||
`long-audio-chunking`: первая убирает файлы вместе с записью, вторая заводит у
|
||||
одной записи несколько копий. Сверять надо при первой же правке файлов, пока
|
||||
сторон две, а не десять.
|
||||
|
||||
Гипотеза прохода `review-adversary` (V5) ревью change
|
||||
`2026-08-23-storage-without-pocketbase`, понижённая триажем: свойство без
|
||||
построенного пути.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Чем держится принадлежность файла записи — сверкой при отдаче, связью в схеме
|
||||
или тем и другим, — и что из этого стоит завести до того, как у записи станет
|
||||
несколько копий?
|
||||
|
||||
## Куда ляжет ответ
|
||||
|
||||
Записка `docs/research/`, а нормативная половина — в спеку `storage`
|
||||
требованием. Из ответа выходят рамки для `delete-record` и
|
||||
`long-audio-chunking`: обе правят обе стороны связи.
|
||||
|
||||
## Рамки
|
||||
|
||||
Раскладку каталога данных ответ не двигает — она объявлена необратимой.
|
||||
Применённые шаги схемы не переписываются: связь в схеме, если она понадобится,
|
||||
приходит новым шагом.
|
||||
@@ -0,0 +1,54 @@
|
||||
# 🧹 Свести норму схемы со служебной таблицей goose
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Та же окрестность, что и сведение документов: норма схемы объявлена в database.md, а держит её сторож
|
||||
- **Зачем:** Таблица учёта goose_db_version несёт второй вид времени и своё умолчание, а норма схемы объявлена абсолютной; сторож перечисляет наши таблицы поимённо и служебной не видит вовсе.
|
||||
- **Теги:** review-2026-08-23, question
|
||||
|
||||
Норма схемы объявлена абсолютной: [database.md](../../docs/database.md)
|
||||
описывает таблицы сервиса, а сторож `internal/archrules` перечисляет их поимённо
|
||||
и сверяет с применённой схемой. Таблицы учёта `goose_db_version` в этом перечне
|
||||
нет, и она под норму не подпадает: время в ней своего вида, у колонки своё
|
||||
умолчание — то и другое ставит goose, а не мы.
|
||||
|
||||
Сегодня расхождение молчит: сторож смотрит только на названные таблицы. Стоит
|
||||
ему начать смотреть на применённую схему целиком, и он покраснеет на таблице,
|
||||
которую мы не заводили и править не можем.
|
||||
|
||||
Исход — выбор нормы, а не правка кода: либо требование сужается и называет
|
||||
таблицу учёта прямо, либо сторож расширяется на всю применённую схему с
|
||||
поимённым исключением.
|
||||
|
||||
Нашёл проход `review-specs` (S2) ревью change
|
||||
`2026-08-23-storage-without-pocketbase`.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- [docs/database.md](../../docs/database.md) — формулировка нормы схемы;
|
||||
- спека `storage` — требование о составе схемы;
|
||||
- сторож `internal/archrules` — перечень таблиц и то, что он сверяет;
|
||||
- каталог шагов `internal/adapter/repo/sqlite/migrations` — только чтением.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Норма схемы называет таблицу учёта явно: либо как исключение, либо как
|
||||
предмет. **Оракул:** чтение `docs/database.md` и спеки рядом — про
|
||||
`goose_db_version` сказано, чья она и почему её колонки другие.
|
||||
- Сторож судит то, что объявлено нормой, и краснеет на расхождении. **Оракул:**
|
||||
мутация — завести колонку мимо шага схемы либо снять таблицу из перечня, и
|
||||
`go test ./internal/archrules/` краснеет.
|
||||
- Служебная таблица сторожа не роняет. **Оракул:** `task gate` зелёный на
|
||||
применённой схеме целиком.
|
||||
|
||||
## Вопросы
|
||||
|
||||
- Что становится нормой: сузить требование и назвать таблицу учёта goose
|
||||
исключением — или расширить сторож на всю применённую схему с поимённым
|
||||
исключением? Первое дешевле и оставляет сторожа слепым к таблице, заведённой
|
||||
мимо шага схемы; второе ловит такую таблицу, но требует держать перечень
|
||||
исключений.
|
||||
|
||||
## Рамки
|
||||
|
||||
Применённые шаги схемы не переписываются. Сама goose не заменяется и её таблица
|
||||
учёта не переименовывается.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 🧹 Проверять ответ обработчика на отказ репозитория
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Подставной репозиторий заводится там же, где тесты конвейера: одна сборка теста на два слоя
|
||||
- **Зачем:** Тесты обработчиков идут по настоящему sqlite без управляемых отказов: класс «база недоступна во время запроса» не проверен нигде.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Тесты обработчиков ходят по настоящему sqlite во временном каталоге. Управляемых
|
||||
отказов у него нет, поэтому весь класс «база недоступна во время запроса» не
|
||||
проверен нигде: что отвечает обработчик, когда репозиторий отказал, известно
|
||||
только чтением кода.
|
||||
|
||||
Проверять надо не отказ базы, а ответ на него: код, тело единой формы и то, что
|
||||
причина не попала в ответ пользователю. Для этого репозиторию нужна подставная
|
||||
реализация, отказывающая по требованию.
|
||||
|
||||
Гипотеза прохода `review-autotests` (A4) ревью change
|
||||
`2026-08-23-storage-without-pocketbase`, понижённая триажем до наблюдения:
|
||||
оракула у неё нет ровно потому, что управляемых отказов не существует. Сам пробел
|
||||
проверен — `grep` по тестам пакета пуст.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- тесты `internal/controller/http` — сборка теста и способ подставить
|
||||
репозиторий;
|
||||
- договор ядра с хранилищем `internal/contract` — интерфейсы, по которым
|
||||
подставляется отказ;
|
||||
- единая форма ответа об отказе `internal/controller/http/errors.go`.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Тест подставляет отказ репозитория. **Оракул:** тест — подставной
|
||||
репозиторий отдаёт ошибку на названном методе, и обработчик её получает.
|
||||
- Ответ на отказ идёт единой формой и не несёт причины. **Оракул:** тест на
|
||||
каждом читающем обработчике — код `500`, тело эталонной формы, текста ошибки
|
||||
базы в теле нет.
|
||||
- Отказ виден владельцу сервиса. **Оракул:** тот же тест с перехваченным
|
||||
журналом — строка уровня `ERROR` с причиной есть.
|
||||
|
||||
## Рамки
|
||||
|
||||
Настоящую базу из тестов не убираем: подставной репозиторий нужен для отказов, а
|
||||
не вместо неё. Формы ответов и коды не меняем — задача проверяет то, что уже
|
||||
объявлено.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 🔬 Имя копии в каталоге записи без обращения к базе
|
||||
|
||||
- **Тип:** research
|
||||
- **Категория:** Очередь — Разведка о той же раскладке каталога данных, и цена ответа растёт с каждой уложенной записью
|
||||
- **Зачем:** Имена копий вида <ULID><ext> без базы не читаются: по каталогу на диске не сказать ни чья запись, ни какого она уровня, а раскладка объявлена необратимой, и цена решения растёт с каждой уложенной записью.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Копии записи лежат именами вида `<ULID><расширение>` в каталоге записи. По
|
||||
каталогу на диске не сказать ни чья запись, ни какого уровня копия: имя читается
|
||||
только вместе с базой.
|
||||
|
||||
Пока записей мало, это не мешает. Цена растёт с каждой уложенной записью:
|
||||
раскладка каталога данных объявлена необратимой, и переименование задним числом
|
||||
стоит прохода по всему каталогу и по всем ссылкам в базе.
|
||||
|
||||
Разведка отвечает не «переименовать ли», а «нужна ли читаемость без базы вообще»
|
||||
— и во что обойдётся её отсутствие, когда записи пойдут живым потоком.
|
||||
|
||||
Нашёл триаж ревью change `2026-08-23-storage-without-pocketbase`.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Нужно ли имени копии нести что-то, по чему запись узнаётся без обращения к базе,
|
||||
и какой ценой это берётся сейчас против той, что придётся заплатить потом?
|
||||
|
||||
## Куда ляжет ответ
|
||||
|
||||
Записка `docs/research/`, а решение — в
|
||||
[ADR о раскладке каталога данных](../../docs/adr/). Изменение самой раскладки
|
||||
необратимо и берётся отдельной задачей после ответа.
|
||||
|
||||
## Рамки
|
||||
|
||||
Раскладка каталога данных этой разведкой не меняется — она объявлена
|
||||
необратимой, и правка её решается человеком. Боевой каталог данных не трогать.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 🧹 Переименовать требование webapp про путь в журнале
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Хвост спек после переезда хранилища, идёт рядом с нормой схемы: оба правят объявленное, а не поведение
|
||||
- **Зачем:** Заголовок требования называет половину нормы — путь чужого корня, — а само требование накрывает обе половины адресного пространства; заголовок это идентичность требования, и синк оставил бы вторую, устаревшую копию.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Задача `storage-without-pocketbase` переписала требование спеки `webapp` про
|
||||
путь в журнале, и оно накрывает теперь обе половины адресного пространства: и путь под корнем приложения, и путь вне корней. Заголовок остался
|
||||
прежним и называет только вторую половину.
|
||||
|
||||
Заголовок оставлен намеренно: он — идентичность требования в секции `MODIFIED`,
|
||||
и переименование при синке оставило бы в спеке вторую, устаревшую копию. Смена
|
||||
имени требует отдельного прохода через `RENAMED`.
|
||||
|
||||
Нашёл синк документации после ревью change
|
||||
`2026-08-23-storage-without-pocketbase`.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- спека `openspec/specs/webapp/spec.md` — заголовок требования про путь в
|
||||
журнале;
|
||||
- change с секцией `RENAMED` и его архив;
|
||||
- ссылки на это требование в `docs/` и в записях каталога задач, если они есть.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Заголовок называет обе половины нормы. **Оракул:** чтение требования —
|
||||
заголовок и тело говорят об одном и том же адресном пространстве.
|
||||
- Второй копии требования в спеке нет. **Оракул:** `grep` по
|
||||
`openspec/specs/webapp/spec.md` — требование о пути в журнале одно.
|
||||
- Спека проходит проверку. **Оракул:** `openspec validate --strict` и `task
|
||||
gate` зелёные.
|
||||
|
||||
## Рамки
|
||||
|
||||
Само поведение не меняется — правится имя требования. Прочие требования спеки
|
||||
`webapp` не трогаются.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 🐞 Судить маршрут по пути из запроса, а не по раскодированной копии
|
||||
|
||||
- **Тип:** fix
|
||||
- **Категория:** Очередь — Правится там же, где маршруты сводятся в одно место: суждение о пути и его объявление — один заход
|
||||
- **Зачем:** Адрес /%6detrics отдаёт метрики байт в байт: ServeMux сравнивает раскодированный путь, а правило прокси написано на литерал /metrics — тот же класс, что закрытый /%5f/.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Правило обратного прокси написано на литерал `/metrics`, а `net/http` сравнивает
|
||||
путь **после** раскодирования процентных последовательностей. Адрес
|
||||
`/%6detrics` правилу прокси не совпадает, а `ServeMux` отдаёт по нему тот же
|
||||
обработчик метрик.
|
||||
|
||||
Тот же класс закрыт задачей `storage-without-pocketbase` для `/%5f/` — там путь
|
||||
исчез вместе с пространством хранилища. Здесь путь остаётся, и обойти можно
|
||||
всякое правило прокси, написанное на литерал.
|
||||
|
||||
Серьёзность понижена тем, что метрики объявлены открытыми без узнавания —
|
||||
[security.md](../../docs/security.md), — то есть сегодня обход не даёт того, чего
|
||||
нельзя получить прямым запросом. Дефект в том, что суждение о пути расходится
|
||||
между сервисом и прокси: следующий закрытый прокси адрес обойдётся так же.
|
||||
|
||||
Нашёл проход `review-adversary` (V3) ревью change
|
||||
`2026-08-23-storage-without-pocketbase`, проверено сырыми запросами.
|
||||
|
||||
## Воспроизведение
|
||||
|
||||
1. Поднять сервис локально.
|
||||
2. `curl -s http://<адрес>/%6detrics` — приходит страница метрик байт в байт,
|
||||
как по `/metrics`.
|
||||
3. Правило прокси, написанное на литерал `/metrics`, такой путь не узнаёт и
|
||||
пропускает его наружу.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- перечень корней сервиса `internal/controller/http/mounts.go` — сравнение пути
|
||||
в `Mount.Covers` и `ExactAddressOf`;
|
||||
- подъём сервера и раздача слушателей в `cmd/transcriber`;
|
||||
- публичный контракт HTTP: адреса `/metrics` и `/health`;
|
||||
- модель угроз `docs/security.md` — строка об открытых метриках и о том, чем
|
||||
держится закрытость адреса.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Перекодированный адрес метрик не отдаёт метрики. **Оракул:** тест маршрутов —
|
||||
`/%6detrics`, `/%6D%65trics` и `/metrics/` отвечают эталоном неизвестного
|
||||
пути, а `/metrics` отвечает метриками.
|
||||
- Тем же эталоном отвечает перекодированный адрес всякого точного корня.
|
||||
**Оракул:** тот же тест на `/%68ealth`.
|
||||
- В журнал перекодированный путь идёт своим полем длины, а не дословно.
|
||||
**Оракул:** тест журнала — строка о таком запросе несёт `<приложение>` либо
|
||||
точный адрес, а не текст спрашивающего.
|
||||
|
||||
## Рамки
|
||||
|
||||
Правило обратного прокси живёт в чужом репозитории `pet-project-server`, и эта
|
||||
задача его не правит: сервис обязан судить о своём адресном пространстве сам.
|
||||
Открытость метрик без узнавания решением не пересматривается — вопрос в том, по
|
||||
какому пути они отдаются.
|
||||
@@ -0,0 +1,48 @@
|
||||
# 🧹 Отдавать файл записи через http.ServeContent
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Стандартная семантика диапазонов нужна проигрыванию записи на экране: браузер тянет звук именно диапазонами
|
||||
- **Зачем:** Отдача файла считает диапазоны байт своей рукой — около 110 строк семантики HTTP, которую стандартная библиотека делает сама, и на этих строках стоит проигрывание записи на экране.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Отдача файла записи разбирает заголовок `Range`, считает границы, ставит
|
||||
`Content-Range` и код `206` своей рукой — около 110 строк семантики HTTP.
|
||||
`http.ServeContent` делает то же самое: диапазоны, `If-Range`, `Last-Modified`,
|
||||
`ETag`, неверный диапазон с кодом `416`.
|
||||
|
||||
Своя реализация стоит не тем, что длиннее, а тем, что расходится: браузер
|
||||
проигрывает звук именно диапазонами, и задача `play-recording-in-app` встанет
|
||||
поверх этих строк.
|
||||
|
||||
Нашёл проход `review-architecture` (вторая половина R2) ревью change
|
||||
`2026-08-23-storage-without-pocketbase`.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- `internal/controller/http/file.go` — разбор `Range`, ответ `206` и заголовки
|
||||
диапазона;
|
||||
- тип содержимого ответа: он выводится из закрытого перечня форматов и остаётся
|
||||
за нами, `ServeContent` его не выбирает;
|
||||
- открытие копии в `internal/adapter/repo/sqlite/store.go` — `ServeContent`
|
||||
требует `io.ReadSeeker` и время последнего изменения;
|
||||
- публичный контракт HTTP: коды `200`, `206`, `416` и заголовки ответа на
|
||||
запрос файла.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Диапазоны считает стандартная библиотека. **Оракул:** `grep` по
|
||||
`internal/controller/http` — ни одного разбора заголовка `Range` руками,
|
||||
вызов `http.ServeContent` один.
|
||||
- Ответы на диапазон не изменились. **Оракул:** тест контроллера — запрос без
|
||||
`Range` отдаёт `200` и всю длину, запрос `bytes=0-99` отдаёт `206` с
|
||||
`Content-Range`, запрос за пределом длины отдаёт `416`.
|
||||
- Тип содержимого по-прежнему выбирает сервис. **Оракул:** тест на записи с
|
||||
расширением `.html` — ответ несёт `application/octet-stream` и
|
||||
`attachment`, а не тип из имени.
|
||||
- Чужой файл остаётся недостижимым. **Оракул:** прежний тест владения —
|
||||
чужая и несуществующая запись отвечают байт в байт одинаково.
|
||||
|
||||
## Рамки
|
||||
|
||||
Раскладку каталога данных и имена копий не трогаем: она объявлена необратимой.
|
||||
Ответ на запрос чужого файла не меняется ни кодом, ни телом.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 🧹 Писать об одном отказе шага одну строку журнала
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Правит цикл воркера следом за мелочами того же цикла: уровень записи и число записей — один заход
|
||||
- **Зачем:** Временный отказ шага доезжает до владельца двумя строками ERROR: шаг пишет ошибку и возвращает её, воркер пишет её снова — в журнале отказов вдвое больше, чем было.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Временный отказ шага конвейера доезжает до владельца сервиса дважды: шаг
|
||||
пишет ошибку в журнал и возвращает её, а цикл воркера пишет её снова строкой
|
||||
`Worker error` — `internal/controller/worker/worker.go`.
|
||||
|
||||
Вопрос стоит триггером ревью в [review.md](../../docs/review.md) с 2026-08-10 —
|
||||
«не удвоилась ли запись об одном сбое» — и остаётся открытым: на прогоне ревью
|
||||
change `2026-08-23-storage-without-pocketbase` удвоение воспроизведено дословным
|
||||
выводом (проход `review-ops`, O2).
|
||||
|
||||
Цена — счёт: всплеск отказов в журнале вдвое больше настоящего, и владелец
|
||||
судит по нему о поломке.
|
||||
|
||||
То же правило для транспорта записано критерием задачи
|
||||
[http-transport-nits](http-transport-nits.md); порознь они потому, что живут в
|
||||
разных пакетах и правятся разными заходами.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- цикл воркера `internal/controller/worker/worker.go` — строка `Worker error`;
|
||||
- шаги конвейера `internal/service` — их собственные записи об отказе;
|
||||
- конвенция [logging.md](../../docs/conventions/logging.md) — кто отвечает за
|
||||
запись об ошибке, родивший её слой или получивший.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Об одном временном отказе шага в журнале одна запись. **Оракул:** тест с
|
||||
перехваченным журналом — шаг отказывает один раз, строк уровня `ERROR` про
|
||||
этот отказ ровно одна.
|
||||
- Запись несёт то, чего у второй стороны нет. **Оракул:** тот же тест — в
|
||||
строке есть имя шага, идентификатор записи и причина.
|
||||
- Молчание не заводится. **Оракул:** тот же тест — отказ шага без записи в
|
||||
журнал не проходит: строк по нему не ноль.
|
||||
|
||||
## Рамки
|
||||
|
||||
`NoopJobError` не логируется и не считается — инвариант, и эта задача его не
|
||||
трогает. Уровни `WARN` и `ERROR` не переопределяются: правит кто пишет, а не чем.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 🧹 Разобрать мелочи хранилища на sqlite
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Мелочи слоя хранилища разбираются, пока слой в руках
|
||||
- **Зачем:** Четыре мелочи ниже потолка триажа в одном пакете: три репозитория не переводят sql.ErrNoRows в доменную ошибку, два числа задают один и тот же предел страницы порознь, Append не заполняет время, которое сам же кладёт в базу, а у selectList — мёртвый довод prefix.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
Четыре мелочи ревью change `2026-08-23-storage-without-pocketbase`, оставшиеся
|
||||
ниже потолка триажа. Собраны одной задачей: все живут в
|
||||
`internal/adapter/repo/sqlite` и правятся одним заходом.
|
||||
|
||||
1. **Репозитории текста, структуры и попытки распознавания не переводят
|
||||
`sql.ErrNoRows` в доменную ошибку**, при том что репозиторий записи в том же
|
||||
пакете правило исполняет (`record_repo.go`). Вызывающий не отличает «записи
|
||||
нет» от отказа базы, потому что типизированного значения ему не приходит.
|
||||
2. **Два числа об одном пределе страницы:** `defaultListLimit = 30` в слое
|
||||
хранилища и `DefaultPageLimit = 30` в контракте. Совпадают дословно, а
|
||||
меняются порознь.
|
||||
3. **`RecordEventRepository.Append` не заполняет `event.CreatedAt`**, хотя само
|
||||
же значение кладёт в базу: вызывающий получает событие с нулевым временем.
|
||||
4. **Мёртвый довод `prefix` у `selectList`** — ни один вызов его не читает.
|
||||
|
||||
Нашёл проход `review-code` (C6 и то, что осталось за его потолком конвенций).
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- `internal/adapter/repo/sqlite` — `text_repo.go`, `recognition_repo.go`,
|
||||
`record_event_repo.go`, `record_list.go`;
|
||||
- `internal/contract` — типизированное значение «записи нет» и константа
|
||||
предела страницы;
|
||||
- конвенция [errors.md](../../docs/conventions/errors.md) — единая точка
|
||||
трансляции ошибки хранилища.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- «Записи нет» приходит вызывающему одним значением из всех репозиториев.
|
||||
**Оракул:** тест на каждый из трёх репозиториев — запрос несуществующего
|
||||
идентификатора отвечает значением, которое узнаётся `errors.Is`, а не
|
||||
`sql.ErrNoRows`.
|
||||
- Предел страницы объявлен одним числом. **Оракул:** `grep -rn "= 30"
|
||||
internal/` — объявление одно, второе место ссылается на него.
|
||||
- Приложенное событие приходит с временем, которое уехало в базу. **Оракул:**
|
||||
тест `Append` — у возвращённого события `CreatedAt` не нулевое и совпадает со
|
||||
строкой в базе.
|
||||
- Мёртвого довода нет. **Оракул:** `go vet ./...` и `golangci-lint run`
|
||||
зелёные, `grep` по `selectList` не находит довода `prefix`.
|
||||
|
||||
## Рамки
|
||||
|
||||
Раскладку каталога данных и имена копий не трогаем — это разведка
|
||||
`record-file-name-readability`. Применённые шаги схемы не переписываются.
|
||||
@@ -3,6 +3,7 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Признак вставшего конвейера — вторая половина той же работы.
|
||||
- **Зачем:** Вставший конвейер неотличим от простоя: возраст задачи в состоянии не считается, и очередь без движения выглядит как отсутствие работы.
|
||||
- **Теги:** review-2026-08-23
|
||||
|
||||
По метрикам видно, что конвейер встал, и это отличимо от «работы нет».
|
||||
|
||||
@@ -10,10 +11,23 @@
|
||||
очереди не считает ничто. Пустая очередь и очередь, где десять задач висят третий
|
||||
час, дают одинаковый нулевой прирост счётчиков.
|
||||
|
||||
**Вторая половина той же слепоты — сам воркер.** Ревью change
|
||||
`2026-08-23-storage-without-pocketbase` (проход `review-ops`, O1) показало: живой
|
||||
воркер, которому не досталось работы, и воркер, чей шаг завис, дают одну и ту же
|
||||
картину — ни строки в журнале, ни движения счётчика. Признак живости здесь свой,
|
||||
отдельный от возраста задачи: очередь бывает пуста законно, а вот прогон цикла
|
||||
случается всегда.
|
||||
|
||||
Считать для этого `NoopJobError` **нельзя** — инвариант CLAUDE.md: значение
|
||||
«задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает
|
||||
уровень. Нужен свой признак: отметка времени последнего прогона цикла у каждого
|
||||
воркера, а не счётчик пустых заходов.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- `internal/metrics` — метрика числа задач по состояниям и возраста самой старой
|
||||
из них;
|
||||
- `internal/metrics` — метрика числа задач по состояниям, возраста самой старой
|
||||
из них и времени последнего прогона цикла у воркера;
|
||||
- цикл воркера `internal/controller/worker` — откуда снимается признак живости;
|
||||
- таблица задач: колонка состояния, по которой считаются числа, и отметка
|
||||
времени перехода, по которой считается возраст самой старой;
|
||||
- `docs/architecture.md`, раздел эксплуатации — что означает каждое число;
|
||||
@@ -27,6 +41,12 @@
|
||||
сменила состояние. Оракул — тест на задаче с заведомо старой отметкой времени.
|
||||
- Съём чисел не мешает работе воркеров. Оракул — чтение кода: запрос идёт по
|
||||
индексу состояния и не берёт захват.
|
||||
- Живой воркер отличим от вставшего. Оракул — тест: у воркера, крутящего цикл на
|
||||
пустой очереди, отметка последнего прогона растёт; у воркера, чей шаг завис
|
||||
дольше периода опроса, стоит на месте.
|
||||
- `NoopJobError` не считается и не пишется. Оракул — тест с перехваченным
|
||||
журналом на пустой очереди: строк о задаче нет, счётчик отказов не вырос,
|
||||
а признак живости при этом двигается.
|
||||
|
||||
## Рамки
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# ✨ Уйти с PocketBase на SQLite со своим каталогом файлов
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Всё остальное строится поверх хранилища: экраны пишутся на типах контроллера, а dev-run-task заводит владельца панели, которой не станет
|
||||
- **Зачем:** Библиотека держит шесть ролей и вышла за адаптер: её типы стоят во всех пяти файлах контроллера и во всех шести его проверках, панель опубликована в интернет вместе с необойдённым /%5f/, а пространство /api/ нельзя закрыть на прокси, потому что за файлами туда ходит браузер пользователя.
|
||||
|
||||
Сервис работает с SQLite напрямую, файлы записей лежат в своём каталоге и
|
||||
отдаются своим обработчиком, маршруты и слои живут на `net/http`, схему двигают
|
||||
свои шаги. Панель администратора исчезает вместе с адресами `/_/` и `/api/`.
|
||||
|
||||
Решение — [ADR-2026-08-22-storage-without-pocketbase](../../docs/adr/ADR-2026-08-22-storage-without-pocketbase.md),
|
||||
разведка — [storage-without-pocketbase](../../docs/research/storage-without-pocketbase.md).
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- каталог `internal/adapter/repo/pocketbase` целиком и его шаги схемы;
|
||||
- каталог `internal/controller/http` целиком вместе с проверками;
|
||||
- подъём сервера и цепочку слоёв в `cmd/transcriber/main.go`;
|
||||
- раскладку каталога данных: файл базы и каталог файлов записей — **необратимое**;
|
||||
- публичный контракт HTTP: пространство `/api/` и адрес `/_/` исчезают, корень
|
||||
`/app/` остаётся;
|
||||
- отдачу файла записи браузеру: короткий токен файла и защищённое поле коллекции;
|
||||
- панель администратора как инструмент владельца сервиса;
|
||||
- ключ конфига `[storage] data_dir` и перечень зависимостей `go.mod`;
|
||||
- модель угроз: адрес `/_/`, дефект `/%5f/` и правило прокси на пространство
|
||||
хранилища.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Библиотеки нет в сборке. **Оракул:** `go list -deps ./... | grep -c pocketbase`
|
||||
даёт `0`, а `go mod tidy` не возвращает её модулей в `go.mod`.
|
||||
- Чужой файл недостижим по прямой ссылке. **Оракул:** тест контроллера — запрос
|
||||
к файлу чужой записи и к несуществующей отвечает одинаково.
|
||||
- Захват записи остаётся неделимым. **Оракул:** тест с параллельными воркерами
|
||||
под `-race` — запись достаётся ровно одному, второй получает отказ по значению
|
||||
признака захвата.
|
||||
- Пространства хранилища не существует. **Оракул:** тест маршрутов — `/api/…`,
|
||||
`/_/` и `/%5f/` отвечают тем же, чем всякий неизвестный путь.
|
||||
- Схема накатывается на пустом каталоге до старта воркеров. **Оракул:** запуск на
|
||||
чистом каталоге данных — ни одного отказа в журнале до первой строки о готовности.
|
||||
|
||||
## Рамки
|
||||
|
||||
Данные не переносятся: стройка, на сервере пусто. Панель не заменяется ничем —
|
||||
остановленную запись возвращает в работу запрос к базе, пока этого не сделают
|
||||
экраны владельца. Очередь остаётся своей таблицей с захватом одним запросом
|
||||
`RETURNING`. Боевой каталог данных не трогать.
|
||||
Reference in New Issue
Block a user