- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
39 KiB
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 запросов в сутки посчитаны, цена не измерена; вопрос остаётся открытым.