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

17 KiB
Raw Blame History

Критерии приёмки

От постановки

Дословно из записи задачи 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. Зависимости и каркас хранилища

  • 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 ./... собирается
  • 1.2 Завести пакет хранилища: создание приложения PocketBase из конфигурации, Bootstrap(), доступ к нему для репозиториев
  • 1.3 Заменить ключи [database] path и [storage] path одним ключом каталога данных (имя выбрано человеком на чекпоинте) в internal/config и в config.dist.toml
  • 1.4 Удалить каталог migrations/*.sql, вшивание его в бинарник и функцию RunMigrations

2. Схема коллекций

  • 2.1 Написать миграцию, заводящую коллекцию files с полями file, location, object_key, size
  • 2.2 Написать миграцию, заводящую коллекцию transcribe_jobs с полями сегодняшней таблицы, без is_error, плюс attempts, плюс значение dead у state
  • 2.3 Задать в схеме ограничения, которые сегодня держит компилятор: ссылка на файл обязательна, state — закрытый перечень, attempts неотрицательно
  • 2.4 Оставить правила доступа обеих коллекций пустыми и проверить, что анонимный запрос к записям получает отказ
  • 2.5 Проверить: на пустом каталоге сервис заводит обе коллекции, на заведённом — не заводит второй раз

3. Репозитории

  • 3.1 Переписать FileRepository на записи коллекции: укладка файла потоком из временного файла, собственное имя вида <идентификатор><расширение>
  • 3.2 Дать FileRepository единый способ выдать рабочую копию файла на диске шагу, которому нужен путь, с уборкой копии на любом исходе
  • 3.3 Переписать TranscriptJobRepository на записи коллекции: Create, Save, GetByID
  • 3.4 Написать FindAndAcquire одним запросом с RETURNING: рост attempts, отбор по состоянию, паузе и сроку захвата, ORDER BY с тай-брейком по ключу
  • 3.5 Все времена очереди писать и сравнивать в том же виде, в каком хранилище пишет created/updated (2006-01-02 15:04:05.000Z, UTC)
  • 3.6 Сделать сохранение результата условным по признаку захвата: чужой захват — отказ сохранения, отличимый от прочих
  • 3.7 Обновить internal/contract под новые обязанности репозиториев; построения ссылки на файл в контракт не заводить
  • 3.8 Удалить пакет internal/adapter/repo/sqlite

4. Очередь: попытки, «мертва», пауза

  • 4.1 Убрать IsError из entity.TranscribeJob, завести Attempts и состояние StateDead
  • 4.2 Обнулять Attempts на каждом шаге, завершившемся без отказа
  • 4.3 Переводить в dead задачу, захваченную с числом попыток сверх предела: перевод делает захвативший, до работы шага
  • 4.4 Сообщать отправителю о переходе в dead тем же путём, каким сообщается отказ шага
  • 4.5 Завести нарастающую паузу перед повтором отказавшей задачи с потолком
  • 4.6 Оставить задержку опроса операции распознавания числом, отдельно от паузы повтора
  • 4.7 Завершать шаг без записи результата и без ответа отправителю, когда захват за время работы достался другому

5. HTTP и панель

  • 5.1 Перевести POST /api/audio и GET /api/status/:id на роутер PocketBase, сохранив имена полей ответа и коды
  • 5.2 Перевести GET /health и GET /metrics туда же
  • 5.3 Переписать main.go: apis.Serve вместо gin, мягкая остановка и таймауты из конфигурации сохраняются
  • 5.4 Повесить хук на правку записи задачи: смена состояния чистит признак захвата, время захвата, паузу и число попыток
  • 5.5 Убедиться, что панель отвечает по /_/, приглашение завести владельца печатается при первом запуске и не печатается после того, как владелец заведён

6. Приём и конвейер

  • 6.1 Перевести приём (createTranscribeJob) на укладку записи в хранилище через временный файл, с уборкой за собой
  • 6.2 Перевести шаги конвертации и распознавания на рабочую копию из 3.2
  • 6.3 Писать в журнал расширение записи собственным полем, а имени файла — ни заданного сервисом, ни того, под которым он лёг в хранилище: имя вместе с идентификатором записи собирает ссылку на скачивание
  • 6.4 Проверить, что имя отправителя не попадает ни в имя файла в хранилище, ни в журнал

7. Проверки

  • 7.1 Переписать проверки приёма по HTTP под новый обработчик, сохранив все сценарии спеки intake, включая запрет имени отправителя в журнале
  • 7.2 Тест захвата: три параллельных вызова по одному состоянию — запись получает ровно один
  • 7.3 Тест протухшего захвата: acquire_time задним числом — задача выдаётся снова
  • 7.4 Тест предела попыток: после заданного числа отказов задача в dead, захвату не выдаётся, видна отбором по состоянию, а отправитель получил сообщение
  • 7.5 Тест нарастающей паузы: вторая пауза длиннее первой
  • 7.6 Тест имени файла: запись с именем секретное-слово.mp3 ложится в хранилище под именем без этого слова и с расширением .mp3
  • 7.7 Тест брошенного пути: задача, чей шаг не дошёл до объявления отказа, после заданного числа захватов уходит в dead
  • 7.8 Тест чужого захвата: шаг, потерявший задачу за время работы, результата не пишет и отправителю не отвечает
  • 7.9 Тест вида времени: время захвата, положенное не нашим кодом, а тем же путём, что created, сравнивается со сроком верно
  • 7.10 Тест повтора: отказ между укладкой файла и сохранением задачи не оставляет задачу со ссылкой на несуществующий файл
  • 7.11 Тест уборки: после отказа шага рабочей копии во временном каталоге не остаётся
  • 7.12 Тест журнала: имени файла в хранилище в журнале нет ни на одном пути

8. Документы

  • 8.1 Переписать docs/database.md: коллекции вместо таблиц, новая таблица настроек с числами (предел попыток, пауза, оба срока захвата с их потолками, задержки опроса), уход goose и goqu
  • 8.2 Поправить CLAUDE.md: строка стека без CGO, запреты с путями под новую раскладку, инвариант «новая колонка в четырёх местах» снять или переписать
  • 8.3 Поправить docs/security.md, раздел «Из чего строятся пути и ключи»: раскладка хранилища, имя отправителя в путь не попадает, ссылка на файл и почему она не уезжает в журнал, приглашение завести владельца
  • 8.4 Поправить docs/architecture.md: компоненты, единые точки проекта, открытые вопросы про хранилище и очередь, преамбула про состояние спек и маркеры долга у «Очереди таблицей»
  • 8.5 Поправить docs/conventions/database.md: миграции больше не goose, время в сыром запросе — тем же видом, что пишет хранилище

9. Сборка и приёмка

  • 9.1 Проверить сборку образа: task image проходит, слой не требует CGO
  • 9.2 task gate зелёный целиком
  • 9.3 Прогон вживую на пустом каталоге с подставным распознавателем: запись через POST /api/audio доходит до done, видна строкой в панели, скачивается по /api/files/... той же длины
  • 9.4 Прогон поверхности: анонимный запрос к записям коллекций и к служебным разделам хранилища получает отказ