## Context Способ решения выбран до этой задачи, и переписывать его здесь незачем: хранилищем становится PocketBase вместе с файлами и панелью ([ADR-2026-08-11-pocketbase-storage-with-admin-panel](../../../docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), замер — [research/pocketbase.md](../../../docs/research/pocketbase.md)), очередь остаётся своей таблицей, но коллекцией той же базы ([ADR-2026-08-11-queue-as-pocketbase-collection](../../../docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение кандидатов — [research/job-queue.md](../../../docs/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/.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 запросов в сутки посчитаны, цена не измерена; вопрос остаётся открытым.