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

39 KiB
Raw Blame History

Context

Способ решения выбран до этой задачи, и переписывать его здесь незачем: хранилищем становится PocketBase вместе с файлами и панелью (ADR-2026-08-11-pocketbase-storage-with-admin-panel, замер — research/pocketbase.md), очередь остаётся своей таблицей, но коллекцией той же базы (ADR-2026-08-11-queue-as-pocketbase-collection, сравнение кандидатов — research/job-queue.md). Ниже — только то, что этими решениями не закрыто.

Сегодня состояние лежит в SQLite через mattn/go-sqlite3, запросы строит goqu, схему двигает goose, файлы лежат плоским каталогом с именами по идентификатору, а HTTP отдаёт gin. Версия PocketBase — та же, что мерила разведка: 0.39.10.

Goals / Non-Goals

Goals:

  • Запись, её метаданные и её файл лежат в одном хранилище и видны владельцу панелью.
  • Захват задачи неделим, у задачи есть предел попыток и состояние «мертва».
  • Сборка перестаёт требовать CGO.
  • Сервис поднимается на чистом каталоге сам.

Non-Goals:

  • Перенос прежних данных. Не переносим и не пытаемся прочитать: решение задачи, а не следствие отказа.
  • Вход пользователей. Провайдер OIDC у коллекции пользователей — задача oidc-login.
  • Закрытие панели снаружи. Это работа выкладки: адрес панели закрывает Authelia на обратном прокси.
  • Отказ от холостого опроса. Три воркера по-прежнему опрашивают базу раз в секунду; открытый вопрос архитектуры остаётся открытым.
  • Удаление файлов и объектов. Хранение бессрочно, задача delete-record своя.
  • Своё резервное копирование. Берём мы встроенное или нет — вопрос не решён и здесь не решается.

Decisions

Приложение поднимает PocketBase само, а не отдаёт ему командную строку

Библиотека умеет запускаться двумя способами: Start() отдаёт процесс её собственному набору команд, а Bootstrap() плюс apis.Serve() оставляет управление нам.

Берём второй. Первый забирает разбор флагов себе, и ключ -c config.toml, объявленный в командах проекта, пришлось бы либо ломать, либо подпирать. Мягкая остановка по сигналу с двумя таймаутами из конфигурации — тоже наша, и отдавать её чужой команде не за что.

Цена названа: набора команд PocketBase у нас не появляется, а вместе с ним нет и команды заведения владельца панели. Чем это закрыто — ниже.

Отвергнуто: Start() с подстройкой флагов — экономит десяток строк и ломает объявленный контракт запуска.

Наши два обработчика переезжают на роутер PocketBase, gin уходит

Панель отдаётся тем же портом, что и приложение, — так решено ADR. Значит порт слушает сервер PocketBase, и второму серверу на том же порту взяться неоткуда.

Наши маршруты — POST /api/audio, GET /api/status/:id, GET /health, GET /metrics — переезжают в его роутер обработчиком на OnServe. Столкновения имён нет: PocketBase занимает /api/collections, /api/files, /api/settings, /api/logs, /api/backups, /api/crons, /api/realtime, /api/batch и /api/health, а /api/audio и /api/status/:id свободны. Публичный контракт API от этого не меняется — меняется то, кто его обслуживает.

Вместе с gin уходит samber/slog-gin, и запросы начинает писать журнал PocketBase. Проверки приёма по HTTP переписываются под новый обработчик; их предмет — коды, поля ответа и запрет имени отправителя в журнале — сохраняется дословно.

Отвергнуто: два сервера на разных портах — наружу опубликован один порт, и панель осталась бы недоступной либо потребовала бы второй маршрут на прокси; ADR решил иначе. gin общим обработчиком под роутером PocketBase — маршруты разбирались бы дважды, а совпадение с чужим путём проявилось бы как молчаливый перехват.

Схему заводят миграции PocketBase, каталог migrations/ уходит

Коллекции files и transcribe_jobs заводит зарегистрированная миграция на Go; apis.Serve применяет непринятые перед стартом сервера. Инвариант проекта «миграция, уехавшая на сервер, не переписывается» переносится дословно: файл шага не правится, изменение — только новым файлом.

Goose, goqu и mattn/go-sqlite3 уходят из зависимостей вместе с каталогом migrations/*.sql и вшиванием его в бинарник.

Состав коллекций

files — по записи на физическую копию, как и сегодня:

Поле Что
file сам файл; пусто у копии в Object Storage
location local или s3
object_key ключ объекта; пусто у местной копии
size размер в байтах

Поле названо location, а не storage, как сегодня, потому что после перевода слово storage занято дважды: так зовётся capability и так зовут само хранилище. Третий смысл в поле записи развёл бы storage.FileRepository и entity.StorageS3 по разным вещам под одним словом, и увидеть это в коде было бы нечем.

transcribe_jobs — задача и она же очередь. Поля сегодняшней таблицы переезжают один в один, кроме трёх мест:

  • is_error уходит. Задача выбывает из выборки состоянием, и способ этот один: два способа разошлись бы, и молчаливо потерялся бы тот, который забыли проверить;
  • state получает значение dead;
  • прибавляется attempts — число попыток.

Идентификаторы записей выдаёт PocketBase — 15 знаков собственного алфавита. Наши UUID уходят: два источника идентификатора в одной таблице дают два формата ссылки на одну сущность. Ответ POST /api/audio при этом продолжает нести job_id строкой — контракт говорит о поле, а не о длине значения.

Инвариант «новая колонка правится в четырёх местах» остаётся, но переезжает. Мест по-прежнему четыре — отображение задачи в запись и обратно, перечень колонок захвата и структура, в которую он читает, — и все четыре лежат в одном пакете, а не в четырёх запросах разных слоёв. Условие, ради которого инвариант писался, при этом не снято: компилятор видит два места из четырёх, и колонка, забытая в паре «перечень — структура», приезжает из захвата нулевой, а первый же Save пишет этот ноль поверх сохранённого. Теряется поле только у задачи, попавшей к воркеру, — то есть тише, чем прежде.

Захват — один запрос с RETURNING мимо записей коллекции

UPDATE transcribe_jobs
   SET acquisition_id = ?, acquire_time = ?, attempts = attempts + 1, updated = ?
 WHERE id = (SELECT id FROM transcribe_jobs
              WHERE state = ? AND (delay_time IS NULL OR delay_time < ?)
                AND (acquisition_id IS NULL OR acquire_time < ?)
              ORDER BY created LIMIT 1)
RETURNING <колонки>

RETURNING в движке за modernc.org/sqlite есть, и замер разведки показал: на трёх горутинах разом запись получает ровно одна. Запрос идёт через app.DB(), который всё, кроме выборок, направляет в пул с единственным соединением, — то есть захваты выстраиваются в очередь, а не соревнуются за файл.

ORDER BY идёт по времени заведения и по ключу записи: время неуникально, и без ключа порядок обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу, зелена через раз.

Время во всех колонках очереди — то же, каким хранилище пишет свои created/updated: строка 2006-01-02 15:04:05.000Z в UTC (types.DateTime библиотеки). Наш запрос кладёт и сравнивает acquire_time, delay_time и updated только через это же значение. Причина не в аккуратности: сравнение строк в SQLite побайтовое, и вид, разошедшийся на разделителе или на дробной части, обращает acquire_time < ? в постоянную истину — тогда любая захваченная задача немедленно достаётся второму воркеру — или в постоянную ложь — тогда брошенная задача не возвращается никогда. Оба исхода тихие, и тест, который сам же кладёт время своим кодом, зелен в обоих.

Хуки коллекции на сыром запросе не срабатывают — цена названа в ADR; поле времени изменения проставляет тот же запрос.

Отвергнуто: захват записями коллекции — это снова два шага без транзакции, ровно то, от чего уходим. Захват в транзакции PocketBase — даёт то же свойство дороже: транзакция на каждый холостой опрос, которых 259 200 в сутки.

Результат пишет только держатель захвата

Неделимость захвата не закрывает всего: захват протухает не только у мёртвого воркера, но и у живого. Конвертация шестичасовой записи идёт дольше часа по построению, а срок захвата на конвертацию сегодня — час.

Отсюда две правки. Первая: сроки захвата привязываются к потолку своего шага, и в таблице настроек стоят рядом с ним. Вторая, и она важнее: запись результата условна по признаку захвата — шаг, чей захват за время работы достался другому, завершается без записи и без ответа отправителю.

Без второго два воркера пишут в одну задачу по очереди: результат первого затирает результат второго, файл второго остаётся сиротой, распознавание уходит в Yandex дважды за наши деньги, а отправитель получает два ответа на одну запись.

Мёртвая задача, попытки и пауза

Три вещи, которые легко свести в одну и нельзя: счётчик попыток, пауза повтора и задержка опроса чужой операции.

Счётчик растёт при каждом захвате и обнуляется, когда шаг завершился без отказа. Рост при захвате, а не при отказе, — единственное, что засчитывает попытку задаче, уносящей с собой процесс: до объявления отказа такая задача не доходит никогда, и по счётчику отказов крутилась бы вечно. Обнуление при успехе делает то же самое с другой стороны: задача, прошедшая конвейер, попыток не копит и до предела не добирается.

Кто переводит в «мертва». Тот, кто захватил задачу с превышенным счётчиком: захват её выдаёт, вызывающий видит перебор, ставит dead, сообщает отправителю и возвращает «работы нет». Условие attempts < предел прямо в отборе не годится — задача исчезла бы из выборки, не получив состояния, то есть выбыла бы молча.

Отправителю сообщается. Переход в «мертва» идёт тем же путём, что отказ шага: инвариант «Принятая запись не теряется молча» допускает два исхода — либо задача пригодна к повтору, либо о неудаче сказано, — и молчаливая смерть не подходит ни под один.

dead и failed — разные приговоры, а не два имени одного. В failed задачу переводит шаг, рассудивший об этой записи окончательно: файл не конвертируется, распознавание вернуло ошибку. В dead задача уходит без такого суждения: мы повторяли и перестали. Ни один шаг конвейера в dead не переводит сам, и выбирать между двумя ему не приходится.

Пауза повтора — функция счётчика, ставится в момент отказа. Задержка опроса чужой операции — число, ставится шагом проверки, и попытку он не тратит, потому что отработал без отказа. Свести их было бы ошибкой ровно потому, что счётчик на ожидании обнулён: пауза выродилась бы в своё наименьшее значение, и опрос SpeechKit участился бы с пяти секунд до одной — вчетверо больше обращений к платному сервису, а отказ по его лимиту тратит попытки уже по-настоящему.

Числа — в таблицу настроек docs/database.md:

Настройка Значение Откуда
Предел попыток 5
Пауза перед повтором 2^(попытка−1) секунд, потолок 5 минут
Срок захвата, конвертация 8 часов потолок записи 6 часов плюс запас
Срок захвата, проверка операции 1 час опрос идёт секунды
Задержка перед первой проверкой операции 10 секунд как сегодня
Задержка между проверками операции 5 секунд как сегодня

Из вариантов эти числа не выбирались, и это честнее назвать, чем оправдать: предел 5 и удвоение паузы — обычное умолчание, а не вывод из замера. Позволительно потому, что числа обратимы — они живут в одной таблице и правятся строкой, в отличие от имени ключа конфигурации и раскладки файлов.

Имя файла в хранилище задаём мы, а не хранилище

Умолчание PocketBase строит имя из имени, данного отправителем: sample.ogg превращается в sample_uztrv6wvz3.ogg — так это замерила разведка, и так это предсказали ADR и модель угроз.

Умолчание не берём. Спека intake уже нормирует обратное: имя отправителя в хранилище не попадает, потому что имя файла кончается в журнале, а имя отправителя в журнал не пишется по инварианту приватности. Имя файла у библиотеки — обычное поле, и мы ставим в него своё: идентификатор с расширением, как сегодня.

Суффикса при этом не появляется, и это выяснилось прогоном: десять случайных знаков дописывает не укладка, а тот самый конструктор имени, который мы обходим. Значит имя в хранилище равно заданному, и защищает ссылку не суффикс, а то, что имени в журнале нет вовсе.

Изъятие из инварианта — расширение, хвост после последней точки — остаётся ровно таким, каким объявлено, и не расширяется до полного имени.

Файл кладётся потоком, а рабочая копия заводится одним способом

Расчётный потолок записи — шесть часов, и в память такая запись не помещается. Библиотека умеет строить файл из пути на диске и читает его потоком. Значит приём пишет тело во временный файл, отдаёт его хранилищу и убирает за собой; каталог временных файлов — общесистемный, не data/.

Обратная сторона — та же и упускается легче. Конвертер и чтение метаданных принимают путь, потому что отдают файл внешней программе: ffmpeg и ffprobe получают имя аргументом. Хранилище пути наружу не даёт, значит между ними нужна рабочая копия — и вот её-то и надо завести одним местом, а не по месту в каждом шаге.

Место это — сам репозиторий файлов: он выдаёт рабочую копию и единственный способ её убрать, а зовёт уборку шаг. Полностью замкнуть уборку на репозиторий — вызовом шага изнутри — мешает конвертация: ей нужны две копии разом, исходник и результат, и вложенные вызовы читались бы хуже, чем два defer подряд. Цена названа: норма держится проверкой, а не построением, и проверки на уборку есть у приёма и у шага конвертации.

Отвергнуто: построение файла из байтов в памяти — проще на строку и роняет процесс на первой же длинной записи. Путь внутрь раскладки хранилища, отданный ffmpeg напрямую — раскладка библиотеки становится нашим контрактом, а требование «файл адресуется записью» не выполняется с первого дня и молча. Перевод конвертера и ffprobe на потоки — дороже всего и упирается в то, что длительность из потока ffprobe отдаёт не всегда.

Панель — вход в задачу, а не окно просмотра

Ради правки задачи панель и покупалась: мёртвая задача оживляется сменой состояния, а не запросом в консоли сервера. Но правка полем в панели идёт мимо кода, который сегодня чистит служебные поля прошлого состояния, — и владелец, «вернувший задачу в работу», получил бы задачу с прежним признаком захвата (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт от первого отказа). Он бы об этом не узнал.

Поэтому переход, сделанный в панели, проходит те же правила, что переход из кода: на правку записи задачи вешается хук, который при смене состояния чистит признак захвата, время захвата, паузу и число попыток. Единая точка перехода остаётся одна, и панель ходит через неё.

Схема при этом держит то, что сегодня держит компилятор: ссылка на файл обязательна, перечень состояний закрыт, число попыток неотрицательно. Задача, заведённая в панели руками, не должна ронять процесс на разыменовании пустой ссылки — а сегодня уронила бы, и вместе с воркером ушли бы бот и приём по HTTP.

Отвергнуто: панель только для чтения по этой коллекции — отнимает ровно то, ради чего перевод затевался. Оставить как есть — перекладывает на владельца знание о четырёх служебных полях, и первая же ошибка тихо ломает задачу.

Потолок размера назван числом, потому что чужие умолчания малы

Прогон показал то, что чтением не видно: нулевой потолок у поля файла библиотека читает не как «без предела», а как своё умолчание в 5 МиБ, а роутер хранилища отсекает тело запроса на 32 МиБ раньше нашего обработчика. Оба умолчания на два-три порядка меньше расчётной записи в шесть часов: приём отказывал бы на всём длиннее примерно пяти минут, а уже принятая запись исчерпывала бы попытки на шаге конвертации — результат в ogg переваливает 5 МиБ примерно на пятой минуте.

Поэтому потолок задан числом и одним: entity.MaxRecordSize, 8 ГиБ, выведено из шести часов с запасом на видео. Тем же числом ограничено тело запроса приёма. Заодно снят таймаут чтения — умолчание в пять минут не переживает заливку шестичасовой записи по медленному каналу, а стойкость к целенаправленной нагрузке объявлена вне модели угроз.

Правила панели стоят на правке запросом, а не на всяком сохранении

Первая редакция вешала их модельным событием, и это оказалось дефектом: событие не различает, кто пишет, и срабатывало на каждом переходе конвейера. Задержка, поставленная шагом вместе со сменой состояния, стиралась тем же сохранением — опрос платного распознавания уходил через секунду вместо десяти, — а число попыток мёртвой задачи, которое переход хранит намеренно, приходило владельцу нулём.

Событие правки запросом различает источник по построению: конвейер пишет мимо HTTP-слоя и под него не попадает.

Поле файла не помечаем защищённым, но ссылка не уезжает в журнал

Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с GET /api/status/:id, а не ниже. Правила доступа коллекций при этом остаются пустыми — то есть перечислить записи может только владелец панели, и подобрать идентификатор снаружи неоткуда.

Отсюда следствие, которого не было при плоском каталоге, и оно меняет смысл изъятия из инварианта приватности. Изъятие выписано под путь на диске: data/files/<uuid>.ogg читателю журнала бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки /api/files/..., по которой запись скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.

Поэтому в журнал идёт расширение собственным полем, а имя файла — ни в каком виде. Прослеживаемость от этого не страдает: требование intake просит идентификатор, расширение и размер, и все три остаются. Изъятие остаётся ровно таким, каким объявлено: расширение, и только оно.

Отказы обрываются там же. Отказ чтения из хранилища несёт ключ файла целиком, отказ выгрузки в Object Storage — полный адрес объекта; обе цепочки %w уехали бы в журнал и собрали бы ссылку не хуже успешного пути. Поэтому наружу идёт свой текст с идентификатором записи, а чужой не оборачивается.

Цена названа: ссылка на файл, единожды утёкшая, работает без ограничения по времени. Разграничение доступа целиком — задачи oidc-login и record-ownership, и до них периметр таков, каким его описывает модель угроз.

Ссылку на файл строит панель, а не наш контракт

Потребителя у ссылки внутри сервиса нет: ответ опроса готовности её не несёт, распознавание берёт содержимое, а панель строит ссылку сама. Поэтому метода «построй ссылку» в договоре ядра с хранилищем не заводим — иначе форма HTTP-пути протекла бы в доменный контракт, а знать о протоколе хранилищу незачем.

Требование «файл отдаётся ссылкой» при этом остаётся: оно нормирует свойство хранилища, а проверяется прогоном — запросом за файлом и сверкой длины. Первым потребителем ссылки станет приложение, и заведёт её себе оно.

Владелец панели заводится ссылкой при первом запуске

Команды заведения владельца у нас нет — её забрал отказ от чужой командной строки. Библиотека закрывает это сама: пока владелец не заведён, при старте сервера она печатает ссылку установки, по которой владелец задаёт себе почту и пароль. Ссылка идёт в журнал контейнера, а журнал читает владелец сервиса.

Ссылка равносильна паролю от панели, поэтому у неё два ограничения, и оба у библиотеки уже есть: тридцать минут жизни (NewStaticAuthToken(30*time.Minute)) и печать только пока владельца нет. Проверено чтением её кода; подтвердить прогоном — шаг приёмки. Бессрочная ссылка в журнале отдала бы панель всякому читателю логов навсегда — при инварианте «строки уже уехали в журнал контейнера» это необратимо.

Ключа конфигурации под пароль не появляется, и это осознанно: секрет, которого в конфигурации нет, не утекает вместе с ней. Хранилище держит только отпечаток.

Отвергнуто: пароль ключом конфигурации — заводит в конфигурации самый чувствительный секрет проекта и ставит его в один ряд с токеном бота, тогда как хранилище умеет обойтись отпечатком.

Ключи конфигурации: два пути заменяются одним каталогом

[database] path и [storage] path уходят: база и файлы съезжаются под один каталог, и по-другому хранилище не умеет.

Имя ключа конфигурации проект объявил необратимым, поэтому решение принял человек 2026-08-11: [storage] data_dir со значением data. Варианты и цена каждого:

  • [storage] data_dirвыбрано. Ключ назван по назначению, как названы и сегодняшние; смена библиотеки через год имени не тронет. Слово storage при этом уже занято capability, но в конфигурации оно значит ровно то же — где лежат данные;
  • [pocketbase] data_dir — прямее всего читается тем, кто знает библиотеку, и вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует второго необратимого переименования;
  • [data] dir — короче и нейтральнее всех, но data в проекте уже значит каталог на диске, и секция с таким именем читается как «настройки каталога», а не «настройки хранилища».

Risks / Trade-offs

  • Правила доступа коллекций оставлены пустыми, а сама база публикует /api/collections/... и служебные разделы наружу → пустое правило значит «только владелец панели», то есть анонимный запрос к записям получает отказ. Проверяется прогоном на живом сервисе, а не рассуждением, и прогон этот — отдельный шаг приёмки.
  • Панель висит на публичном порту → закрывает её Authelia на обратном прокси; это работа выкладки, и до неё панель открыта всякому, кто знает адрес. Записано моделью угроз, задачи в беклоге нет намеренно.
  • Захват идёт сырым запросом мимо записей коллекции → правка состава колонок очереди перестаёт быть видной компилятору в этом одном месте. Держится тестом захвата, который читает захваченную задачу целиком.
  • Число попыток растёт при захвате → задача, которую бросают по независящей от неё причине (перезапуск сервиса), тратит попытки. Смягчение: счётчик обнуляется на каждом шаге, завершившемся без отказа, поэтому пять перезапусков подряд должны прийтись на одну и ту же задачу, чтобы её убить.
  • Задача умирает молча, если сообщение отправителю не дошло → переход в «мертва» отвечает тем же путём, что и отказ, и отказ отправки логируется так же. Гарантии доставки у нас нет ни там, ни там, и этой задачей она не заводится.
  • Проверки приёма по HTTP переписываются целиком → предмет проверок при этом не меняется, и расхождение поймает сравнение с прежним списком сценариев спеки intake.
  • Идентификаторы задач меняют формат → внешняя программа, хранящая прежние идентификаторы, их не найдёт. Прежних данных нет по решению задачи, поэтому цена нулевая — но названа, потому что при переносе данных была бы не нулевой.

Migration Plan

Переноса нет. Сервис поднимается на чистом каталоге данных; момент перехода на сервере назначает человек, и до него прежний каталог остаётся нетронутым.

Откат — возврат прежнего образа и прежнего каталога data/: новый каталог данных заводится рядом, старого не трогает.

Open Questions

  • Своё резервное копирование PocketBase — берём или оставляем серверу; открытый вопрос архитектуры, этой задачей не закрывается.
  • Отказ от холостого опроса — 259 200 запросов в сутки посчитаны, цена не измерена; вопрос остаётся открытым.