- Клиент бота собирается один раз и достаётся отправителю и транспорту; разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание при сборке ограничено сроком — иначе молчащий Telegram вешал подъём. - Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой, уровень по причине — WARN для неподнятого входа, ERROR для неназванного адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count. - Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше обращения к клиенту, то есть мимо чистки на его границе.
16 KiB
Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
Хранилище — встроенная PocketBase 0.39.10: она держит и базу, и файлы
записей под одним каталогом данных. Ключ конфигурации — [storage] data_dir,
умолчание data. В SQLite библиотека ходит через modernc.org/sqlite, поэтому
CGO сборке не нужен.
Схему двигают шаги миграций PocketBase на Go, каталог
internal/adapter/repo/pocketbase/migrations, файл на шаг и имя файла — имя
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
хранилища (pocketbase.New), прежде чем стартуют воркеры и сервер. Применённый
шаг не переписывается — изменение только новым шагом: применённое хранилище
считает по имени шага.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: шаг гейта сверяет изменённые шаги схемы с правкой этого документа по префиксу пути, а префикс наводится только на каталог. Где этот префикс задан — conventions/go-linters.md, «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет репозитория берёт их оттуда.
Идентификаторы записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в именах файлов: имя, под которым запись ложится в
хранилище, задаёт сервис, и это <uuid><расширение>.
Время — вид хранилища: строка 2006-01-02 15:04:05.000Z в UTC. Колонки
created и updated проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — тем же видом, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не повторяем: перечень единых точек и их отсутствий держит architecture.md, «Единые точки проекта».
Коллекции
files
Один файл на одну физическую копию: исходник, результат конвертации и копия в Object Storage — три разные записи.
| Поле | Тип | Что |
|---|---|---|
id |
TEXT PK | Идентификатор записи, выдаёт хранилище |
file |
file | Сам файл; пусто у копии в Object Storage |
location |
select | local или s3 |
object_key |
TEXT | Ключ объекта; пусто у местной копии |
size |
INTEGER | Размер в байтах |
created, updated |
DATETIME | Проставляет хранилище |
Поле названо location, а не storage: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
transcribe_jobs
Задача расшифровки и она же очередь.
| Поле | Тип | Что |
|---|---|---|
id |
TEXT PK | Идентификатор записи, выдаёт хранилище |
state |
select | created, converted, transcribe, done, failed, dead; перечень закрыт схемой |
source |
select | api, telegram, unknown |
file |
relation → files |
Текущий файл задачи: шаг конвейера переставляет ссылку на свой результат |
delay_time |
DATETIME | Не брать задачу раньше этого времени |
acquisition_id |
TEXT | Кто захватил задачу |
acquire_time |
DATETIME | Когда захватил; по нему считается протухание |
attempts |
INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
recognition_op_id |
TEXT | Идентификатор операции в Yandex Cloud |
transcription_text |
editor | Результат распознавания |
error_text |
TEXT | Текст ошибки, машинный |
tg_chat_id |
INTEGER | Куда отправить результат |
tg_reply_message_id |
INTEGER | С каким сообщением связать |
created, updated |
DATETIME | Проставляет хранилище |
Индекс один — по state: выборка воркера идёт по нему, паузе и сроку захвата.
Прежней колонки is_error нет: задача выбывает из выборки состоянием, и способ
этот один.
Состояния failed и dead — разные приговоры, и чей это приговор, нормирует
pipeline, «Число попыток и состояние
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
потребует нового шага.
Правила доступа обеих коллекций пусты, то есть перечислять и читать записи
может только владелец панели. Проверено прогоном: анонимный запрос к
/api/collections/*/records отвечает 403, к /api/logs, /api/backups,
/api/settings и /api/crons — 401.
Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- Расшифровка лежит целиком в поле
transcription_textодной строкой. Запись длиной в час даёт десятки килобайт в одной ячейке; читается она целиком при каждом чтении задачи и при каждом захвате. - Аудио лежит в раскладке хранилища:
data/storage/<коллекция>/<запись>/<имя>рядом с файлом атрибутов. Имя задаёт сервис —<uuid><расширение>; собственного суффикса хранилище не дописывает, потому что умолчание, строящее имя из имени отправителя, не применяется. Ни файлы, ни объекты в Object Storage не удаляются после завершения задачи: каталог и бакет растут неограниченно. - Файл отдаётся ссылкой
/api/files/<коллекция>/<запись>/<имя>. Поле файла помечено защищённым шагом202608120001, а правило просмотра коллекции пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт знание её идентификатора» — отменено задачейoidc-login2026-08-12. Имя файла в хранилище в журнал не пишется по-прежнему: оно последняя часть ссылки. - Коллекция
usersзаводится самой библиотекой, а шаг202608120001её сужает: создание записи разрешено только контексту обмена OIDC (@request.context = "oauth2"), вход по паролю и одноразовый код выключены. Без этого сужения закрытие API обходится двумя запросами — завести себе запись и войти паролем. Продление сессии закрыто слоем в приложении, а не настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда. - Захват задачи — один запрос с
RETURNING, мимо записей коллекции.app.DB()направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени заведения и по ключу: время неуникально, и без ключа порядок обработки невоспроизводим. - Запись результата условна по признаку захвата — инвариант «Результат пишет только держатель захвата» в CLAUDE.md, «Инварианты» (major); норма — pipeline. Здесь названо потому, что условие проверяется тем же запросом, что и сам захват.
- Список колонок задан четырьмя местами —
applyToRecord,recordToJob, константойacquireColumnsи структуройacquiredRow, — плюс шагом схемы. Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его серьёзность (critical/major) — инвариант в CLAUDE.md, «Инварианты». - Отказ хранилища наружу не выходит дословно. Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку
%wобрывают. То же у выгрузки в Object Storage: отказ SDK несёт полный URL объекта.
Настройки с числовым значением
| Настройка | Значение | Где | Откуда число |
|---|---|---|---|
| Предел попыток | 5 | service/transcribe.go |
обычное умолчание, не замер |
| Пауза перед повтором | 2^(попытка−1) с, потолок 5 минут |
там же | то же |
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
| Срок захвата, распознавание | 8 часов | там же | то же |
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между попытками | 1 секунда | controller/worker/worker.go |
как было |
| Предел длины сообщения Telegram | 4000 символов | adapter/telegram/sender.go |
предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, [server] shutdown_timeout |
— |
| Таймаут жёсткой остановки | 20 секунд | конфиг, [server] force_shutdown_timeout |
— |
| Таймаут обновлений Telegram | 10 секунд | конфиг, [telegram] update_timeout |
— |
| Срок ожидания Telegram при сборке клиента | 10 секунд | adapter/telegram.ProbeTimeout |
решение, не замер: одно обращение за getMe укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
| Качество кодирования vorbis | -q:a 4 |
adapter/converter/ffmpeg/ffmpeg.go |
— |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | entity.MaxRecordSize |
расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | нормирует access | pbrepo.SessionDuration, ставится при подъёме |
решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
| Потолок времени на вход у провайдера | 10 минут | controller/http/auth.go |
дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
Потолок размера назван числом в двух местах сразу — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по медленному каналу переживают любой фиксированный, а стойкость к целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек нет: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.