Files
transcriber/openspec/changes/archive/2026-08-12-pocketbase-storage/tasks.md
T
av 01cc31d45f хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
2026-08-12 08:31:59 +03:00

197 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Критерии приёмки
### От постановки
Дословно из записи задачи `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 Прогон поверхности: анонимный запрос к записям коллекций и к служебным
разделам хранилища получает отказ