хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
## Критерии приёмки
|
||||
|
||||
### От постановки
|
||||
|
||||
Дословно из записи задачи `pocketbase-storage`. Файл задачи закрытие удалит —
|
||||
критерии обязаны его пережить. Одно уточнение внесено ревью дизайна и отмечено
|
||||
курсивом: прогон на реальных ключах Yandex запрещён проектом, поэтому
|
||||
распознаватель в прогоне подставной.
|
||||
|
||||
- Сервис поднимается на чистом каталоге данных, накатывает свою схему сам и
|
||||
принимает запись обоими входами. Оракул — запуск на пустом `data/` и прогон
|
||||
записи из Telegram и через `POST /api/audio` до состояния `done`
|
||||
*с подставным распознавателем `internal/adapter/recognizer/memory.go`*.
|
||||
- Захват задачи воркером идёт одним запросом и не выдаёт одну запись двум
|
||||
вызывающим. Оракул — тест на трёх параллельных вызовах захвата по одному
|
||||
состоянию: ровно один получает запись.
|
||||
- Задача, брошенная на середине, достаётся снова по истечении срока захвата, а
|
||||
падающая всегда — уходит в «мертва» и из выборки исчезает. Оракулы — тест с
|
||||
проставленным задним числом `acquire_time` и тест с шагом, падающим на каждой
|
||||
попытке: после заданного их числа задача не выдаётся, а её состояние видно
|
||||
отбором.
|
||||
- Принятая запись видна в панели строкой и скачивается по ссылке
|
||||
`/api/files/...` тем же файлом. Оракулы — прогон записи через
|
||||
`POST /api/audio` на пустом каталоге, затем поиск её строки в коллекции задач
|
||||
на `/_/` по идентификатору и запрос `/api/files/...` за тем же файлом: длина
|
||||
совпадает с загруженной.
|
||||
- `docs/database.md` описывает новую схему, а старые упоминания goose и goqu из
|
||||
документов канона убраны. Оракул — `task gate`, шаг `docs.py check`.
|
||||
|
||||
### От ревью дизайна (рубрика прохода `rubric`)
|
||||
|
||||
Свойства узла, порождённые до чтения артефактов. Пункты 1, 11 и 12 закрыты
|
||||
дельта-спеками, 7 неприменим по объявленному Non-Goal, остальные проверяются
|
||||
поимённо.
|
||||
|
||||
- **Захват атомарен.** Критерий успеха — сам факт возврата записи, а не
|
||||
последующее чтение; ноль записей отличается от отказа. Оракул — 7.2.
|
||||
- **Протухший захват не создаёт двух живых исполнителей.** Срок захвата назван
|
||||
числом и не меньше худшего времени шага; запись результата условна по
|
||||
владельцу захвата. Оракулы — 7.3 и 7.8.
|
||||
- **У каждого пути выбывания назван актор перехода.** Отказ шага, брошенная
|
||||
задача, гибель процесса — все три доходят до «мертва». Оракулы — 7.4 и 7.7.
|
||||
- **Узел читает состояние, которое сам же меняет.** Порядок выборки
|
||||
детерминирован и имеет тай-брейк по ключу; значения, выведенные из счётчика,
|
||||
определены при любом порядке параллельных операций. Оракулы — 7.2 и 7.9.
|
||||
- **Идемпотентность повтора.** Падение между «работа сделана» и «результат
|
||||
записан» не создаёт при повторе второго файла и второй записи; ссылка на файл
|
||||
переставляется только после того, как запись о новом файле существует. Оракул
|
||||
— 7.10.
|
||||
- **Атомарность записи файла и уборка временного.** Обрыв и отмена не оставляют
|
||||
читаемого огрызка; рабочая копия убирается на всех ветках выхода. Оракул —
|
||||
7.11.
|
||||
- **Ссылка на файл: кто вправе по ней пройти.** Названы право и неугадываемость;
|
||||
ссылка не оседает там, где её прочтут посторонние. Оракулы — 7.12 и 9.4.
|
||||
- **Границы транзакции и отмена контекста.** Частичный переход невозможен либо
|
||||
назван и компенсирован порядком операций. Оракул — 7.10.
|
||||
- **Источник времени и идентификаторов един.** Один формат и одна зона у всех,
|
||||
кто колонку времени пишет и сравнивает, включая запросы мимо слоя записей.
|
||||
Оракул — 7.9.
|
||||
|
||||
## 1. Зависимости и каркас хранилища
|
||||
|
||||
- [x] 1.1 Добавить `github.com/pocketbase/pocketbase` v0.39.10, убрать
|
||||
`mattn/go-sqlite3`, `doug-martin/goqu/v9`, `pressly/goose/v3`, `gin-gonic/gin`,
|
||||
`samber/slog-gin`; `go mod tidy` проходит, `CGO_ENABLED=0 go build ./...`
|
||||
собирается
|
||||
- [x] 1.2 Завести пакет хранилища: создание приложения PocketBase из
|
||||
конфигурации, `Bootstrap()`, доступ к нему для репозиториев
|
||||
- [x] 1.3 Заменить ключи `[database] path` и `[storage] path` одним ключом
|
||||
каталога данных (имя выбрано человеком на чекпоинте) в `internal/config` и в
|
||||
`config.dist.toml`
|
||||
- [x] 1.4 Удалить каталог `migrations/*.sql`, вшивание его в бинарник и функцию
|
||||
`RunMigrations`
|
||||
|
||||
## 2. Схема коллекций
|
||||
|
||||
- [x] 2.1 Написать миграцию, заводящую коллекцию `files` с полями `file`,
|
||||
`location`, `object_key`, `size`
|
||||
- [x] 2.2 Написать миграцию, заводящую коллекцию `transcribe_jobs` с полями
|
||||
сегодняшней таблицы, без `is_error`, плюс `attempts`, плюс значение `dead` у
|
||||
`state`
|
||||
- [x] 2.3 Задать в схеме ограничения, которые сегодня держит компилятор: ссылка
|
||||
на файл обязательна, `state` — закрытый перечень, `attempts` неотрицательно
|
||||
- [x] 2.4 Оставить правила доступа обеих коллекций пустыми и проверить, что
|
||||
анонимный запрос к записям получает отказ
|
||||
- [x] 2.5 Проверить: на пустом каталоге сервис заводит обе коллекции, на
|
||||
заведённом — не заводит второй раз
|
||||
|
||||
## 3. Репозитории
|
||||
|
||||
- [x] 3.1 Переписать `FileRepository` на записи коллекции: укладка файла потоком
|
||||
из временного файла, собственное имя вида `<идентификатор><расширение>`
|
||||
- [x] 3.2 Дать `FileRepository` единый способ выдать рабочую копию файла на
|
||||
диске шагу, которому нужен путь, с уборкой копии на любом исходе
|
||||
- [x] 3.3 Переписать `TranscriptJobRepository` на записи коллекции: `Create`,
|
||||
`Save`, `GetByID`
|
||||
- [x] 3.4 Написать `FindAndAcquire` одним запросом с `RETURNING`: рост
|
||||
`attempts`, отбор по состоянию, паузе и сроку захвата, `ORDER BY` с
|
||||
тай-брейком по ключу
|
||||
- [x] 3.5 Все времена очереди писать и сравнивать в том же виде, в каком
|
||||
хранилище пишет `created`/`updated` (`2006-01-02 15:04:05.000Z`, UTC)
|
||||
- [x] 3.6 Сделать сохранение результата условным по признаку захвата: чужой
|
||||
захват — отказ сохранения, отличимый от прочих
|
||||
- [x] 3.7 Обновить `internal/contract` под новые обязанности репозиториев;
|
||||
построения ссылки на файл в контракт не заводить
|
||||
- [x] 3.8 Удалить пакет `internal/adapter/repo/sqlite`
|
||||
|
||||
## 4. Очередь: попытки, «мертва», пауза
|
||||
|
||||
- [x] 4.1 Убрать `IsError` из `entity.TranscribeJob`, завести `Attempts` и
|
||||
состояние `StateDead`
|
||||
- [x] 4.2 Обнулять `Attempts` на каждом шаге, завершившемся без отказа
|
||||
- [x] 4.3 Переводить в `dead` задачу, захваченную с числом попыток сверх предела:
|
||||
перевод делает захвативший, до работы шага
|
||||
- [x] 4.4 Сообщать отправителю о переходе в `dead` тем же путём, каким сообщается
|
||||
отказ шага
|
||||
- [x] 4.5 Завести нарастающую паузу перед повтором отказавшей задачи с потолком
|
||||
- [x] 4.6 Оставить задержку опроса операции распознавания числом, отдельно от
|
||||
паузы повтора
|
||||
- [x] 4.7 Завершать шаг без записи результата и без ответа отправителю, когда
|
||||
захват за время работы достался другому
|
||||
|
||||
## 5. HTTP и панель
|
||||
|
||||
- [x] 5.1 Перевести `POST /api/audio` и `GET /api/status/:id` на роутер
|
||||
PocketBase, сохранив имена полей ответа и коды
|
||||
- [x] 5.2 Перевести `GET /health` и `GET /metrics` туда же
|
||||
- [x] 5.3 Переписать `main.go`: `apis.Serve` вместо gin, мягкая остановка и
|
||||
таймауты из конфигурации сохраняются
|
||||
- [x] 5.4 Повесить хук на правку записи задачи: смена состояния чистит признак
|
||||
захвата, время захвата, паузу и число попыток
|
||||
- [x] 5.5 Убедиться, что панель отвечает по `/_/`, приглашение завести владельца
|
||||
печатается при первом запуске и не печатается после того, как владелец заведён
|
||||
|
||||
## 6. Приём и конвейер
|
||||
|
||||
- [x] 6.1 Перевести приём (`createTranscribeJob`) на укладку записи в хранилище
|
||||
через временный файл, с уборкой за собой
|
||||
- [x] 6.2 Перевести шаги конвертации и распознавания на рабочую копию из 3.2
|
||||
- [x] 6.3 Писать в журнал расширение записи собственным полем, а имени файла —
|
||||
ни заданного сервисом, ни того, под которым он лёг в хранилище: имя вместе с
|
||||
идентификатором записи собирает ссылку на скачивание
|
||||
- [x] 6.4 Проверить, что имя отправителя не попадает ни в имя файла в хранилище,
|
||||
ни в журнал
|
||||
|
||||
## 7. Проверки
|
||||
|
||||
- [x] 7.1 Переписать проверки приёма по HTTP под новый обработчик, сохранив все
|
||||
сценарии спеки `intake`, включая запрет имени отправителя в журнале
|
||||
- [x] 7.2 Тест захвата: три параллельных вызова по одному состоянию — запись
|
||||
получает ровно один
|
||||
- [x] 7.3 Тест протухшего захвата: `acquire_time` задним числом — задача выдаётся
|
||||
снова
|
||||
- [x] 7.4 Тест предела попыток: после заданного числа отказов задача в `dead`,
|
||||
захвату не выдаётся, видна отбором по состоянию, а отправитель получил
|
||||
сообщение
|
||||
- [x] 7.5 Тест нарастающей паузы: вторая пауза длиннее первой
|
||||
- [x] 7.6 Тест имени файла: запись с именем `секретное-слово.mp3` ложится в
|
||||
хранилище под именем без этого слова и с расширением `.mp3`
|
||||
- [x] 7.7 Тест брошенного пути: задача, чей шаг не дошёл до объявления отказа,
|
||||
после заданного числа захватов уходит в `dead`
|
||||
- [x] 7.8 Тест чужого захвата: шаг, потерявший задачу за время работы, результата
|
||||
не пишет и отправителю не отвечает
|
||||
- [x] 7.9 Тест вида времени: время захвата, положенное **не** нашим кодом, а тем
|
||||
же путём, что `created`, сравнивается со сроком верно
|
||||
- [x] 7.10 Тест повтора: отказ между укладкой файла и сохранением задачи не
|
||||
оставляет задачу со ссылкой на несуществующий файл
|
||||
- [x] 7.11 Тест уборки: после отказа шага рабочей копии во временном каталоге
|
||||
не остаётся
|
||||
- [x] 7.12 Тест журнала: имени файла в хранилище в журнале нет ни на одном пути
|
||||
|
||||
## 8. Документы
|
||||
|
||||
- [x] 8.1 Переписать `docs/database.md`: коллекции вместо таблиц, новая таблица
|
||||
настроек с числами (предел попыток, пауза, оба срока захвата с их потолками,
|
||||
задержки опроса), уход goose и goqu
|
||||
- [x] 8.2 Поправить `CLAUDE.md`: строка стека без CGO, запреты с путями под новую
|
||||
раскладку, инвариант «новая колонка в четырёх местах» снять или переписать
|
||||
- [x] 8.3 Поправить `docs/security.md`, раздел «Из чего строятся пути и ключи»:
|
||||
раскладка хранилища, имя отправителя в путь **не** попадает, ссылка на файл и
|
||||
почему она не уезжает в журнал, приглашение завести владельца
|
||||
- [x] 8.4 Поправить `docs/architecture.md`: компоненты, единые точки проекта,
|
||||
открытые вопросы про хранилище и очередь, преамбула про состояние спек и
|
||||
маркеры долга у «Очереди таблицей»
|
||||
- [x] 8.5 Поправить `docs/conventions/database.md`: миграции больше не goose,
|
||||
время в сыром запросе — тем же видом, что пишет хранилище
|
||||
|
||||
## 9. Сборка и приёмка
|
||||
|
||||
- [x] 9.1 Проверить сборку образа: `task image` проходит, слой не требует CGO
|
||||
- [x] 9.2 `task gate` зелёный целиком
|
||||
- [x] 9.3 Прогон вживую на пустом каталоге с подставным распознавателем: запись
|
||||
через `POST /api/audio` доходит до `done`, видна строкой в панели, скачивается
|
||||
по `/api/files/...` той же длины
|
||||
- [x] 9.4 Прогон поверхности: анонимный запрос к записям коллекций и к служебным
|
||||
разделам хранилища получает отказ
|
||||
Reference in New Issue
Block a user