учёт: закрыт переезд хранилища, заведён урожай его ревью
- storage-without-pocketbase закрыта как реализованная: приёмка сошлась по всем
пяти критериям записи и по двенадцати приёмочным свойствам рубрики ревью
дизайна, работа лежит коммитом c9b7765
- урожай триажа ревью развёрнут в одиннадцать записей с тегом партии
review-2026-08-23 и расставлен по зависимости, а не в конец списка
- находка про признак живости воркера слита в stalled-pipeline-metric: у неё та
же причина — вставший конвейер неотличим от простоя
This commit is contained in:
+11
-1
@@ -43,31 +43,41 @@
|
|||||||
|
|
||||||
## Очередь
|
## Очередь
|
||||||
|
|
||||||
- [✨ Уйти с PocketBase на SQLite со своим каталогом файлов](items/storage-without-pocketbase.md) — Библиотека держит шесть ролей и вышла за адаптер: её типы стоят во всех пяти файлах контроллера и во всех шести его проверках, панель опубликована в интернет вместе с необойдённым /%5f/, а пространство /api/ нельзя закрыть на прокси, потому что за файлами туда ходит браузер пользователя.
|
|
||||||
- [🧹 Поднимать сервис для локальной работы одной командой](items/dev-run-task.md) — Локальная проверка требует ручной чистки каталога данных и остановки процесса, а владельца панели заводят по одноразовой ссылке из журнала.
|
- [🧹 Поднимать сервис для локальной работы одной командой](items/dev-run-task.md) — Локальная проверка требует ручной чистки каталога данных и остановки процесса, а владельца панели заводят по одноразовой ссылке из журнала.
|
||||||
|
- [🧹 Вынести тело подкоманды resume из main-пакета и покрыть тестом](items/devtools-resume-testable.md) — go test ./cmd/... отвечает no test files: подкоманда возврата записи в работу не проверена ничем, а тест на неё пришлось бы писать, повторяя её тело руками.
|
||||||
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
||||||
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
||||||
- [✨ Дать владельцу править запись, возвращать её в работу и видеть её путь](items/audiorecord-actions.md) — С записью нельзя сделать ничего: заголовок ставит одна языковая модель, остановленную возвращает в работу только владелец сервиса в панели, а журнал событий пишется и не читается никем, кроме него же
|
- [✨ Дать владельцу править запись, возвращать её в работу и видеть её путь](items/audiorecord-actions.md) — С записью нельзя сделать ничего: заголовок ставит одна языковая модель, остановленную возвращает в работу только владелец сервиса в панели, а журнал событий пишется и не читается никем, кроме него же
|
||||||
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
||||||
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в cmd/transcriber проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в cmd/transcriber проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
||||||
|
- [🐞 Судить маршрут по пути из запроса, а не по раскодированной копии](items/route-by-escaped-path.md) — Адрес /%6detrics отдаёт метрики байт в байт: ServeMux сравнивает раскодированный путь, а правило прокси написано на литерал /metrics — тот же класс, что закрытый /%5f/.
|
||||||
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
||||||
- [🧹 Свести пять расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
- [🧹 Свести пять расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
||||||
|
- [🧹 Свести норму схемы со служебной таблицей goose](items/goose-table-in-schema-norm.md) — Таблица учёта goose_db_version несёт второй вид времени и своё умолчание, а норма схемы объявлена абсолютной; сторож перечисляет наши таблицы поимённо и служебной не видит вовсе.
|
||||||
|
- [🧹 Переименовать требование webapp про путь в журнале](items/rename-webapp-journal-requirement.md) — Заголовок требования называет половину нормы — путь чужого корня, — а само требование накрывает обе половины адресного пространства; заголовок это идентичность требования, и синк оставил бы вторую, устаревшую копию.
|
||||||
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
||||||
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Скрипту недоступны ни браузерная сессия, ни вход у Authelia: домен целиком стоит за прокси, и автоматизировать загрузку нечем.
|
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Скрипту недоступны ни браузерная сессия, ни вход у Authelia: домен целиком стоит за прокси, и автоматизировать загрузку нечем.
|
||||||
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
|
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
|
||||||
|
- [🧹 Проверять ответ обработчика на отказ репозитория](items/http-tests-repo-failure-injection.md) — Тесты обработчиков идут по настоящему sqlite без управляемых отказов: класс «база недоступна во время запроса» не проверен нигде.
|
||||||
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
|
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
|
||||||
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
|
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
|
||||||
- [🐞 Различать отказ, который стоит повторить, и приговор записи](items/failure-verdict-vs-retry.md) — Отказ приведения останавливает запись с первой попытки, и предел в пять отказов не работает никогда: разовый сбой ffmpeg останавливает запись приговором, хотя повтор обработал бы её успешно.
|
- [🐞 Различать отказ, который стоит повторить, и приговор записи](items/failure-verdict-vs-retry.md) — Отказ приведения останавливает запись с первой попытки, и предел в пять отказов не работает никогда: разовый сбой ffmpeg останавливает запись приговором, хотя повтор обработал бы её успешно.
|
||||||
|
- [🐞 Не закрывать базу, пока живёт брошенный воркер](items/db-closed-under-live-worker.md) — По истечении жёсткого таймаута остановки процесс возвращается из run(), отложенный db.Close() обнуляет пулы, брошенный воркер разыменует nil и роняет процесс паникой; захват записи после этого стоит до восьми часов, и следа не остаётся.
|
||||||
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
|
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
|
||||||
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
||||||
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
|
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
|
||||||
|
- [🧹 Писать об одном отказе шага одну строку журнала](items/single-journal-line-per-step-failure.md) — Временный отказ шага доезжает до владельца двумя строками ERROR: шаг пишет ошибку и возвращает её, воркер пишет её снова — в журнале отказов вдвое больше, чем было.
|
||||||
|
- [🧹 Разобрать мелочи хранилища на sqlite](items/sqlite-repo-nits.md) — Четыре мелочи ниже потолка триажа в одном пакете: три репозитория не переводят sql.ErrNoRows в доменную ошибку, два числа задают один и тот же предел страницы порознь, Append не заполняет время, которое сам же кладёт в базу, а у selectList — мёртвый довод prefix.
|
||||||
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
|
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
|
||||||
|
- [🧹 Отдавать файл записи через http.ServeContent](items/serve-content-for-file-ranges.md) — Отдача файла считает диапазоны байт своей рукой — около 110 строк семантики HTTP, которую стандартная библиотека делает сама, и на этих строках стоит проигрывание записи на экране.
|
||||||
- [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
|
- [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
|
||||||
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
||||||
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
||||||
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
|
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
|
||||||
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
|
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
|
||||||
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
|
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
|
||||||
|
- [🔬 Сверка файла с записью при отдаче](items/file-record-ownership-check.md) — Владение судится у записи, а файл открывается по её ссылке без сверки files.record_id и files.owner_id; схема этой связи не держит, и обе стороны будут править delete-record и long-audio-chunking.
|
||||||
|
- [🔬 Имя копии в каталоге записи без обращения к базе](items/record-file-name-readability.md) — Имена копий вида <ULID><ext> без базы не читаются: по каталогу на диске не сказать ни чья запись, ни какого она уровня, а раскладка объявлена необратимой, и цена решения растёт с каждой уложенной записью.
|
||||||
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
||||||
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
|
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
|
||||||
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
||||||
|
|||||||
@@ -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
|
- **Тип:** 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`, раздел эксплуатации — что означает каждое число;
|
- `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