Files
transcriber/docs/database.md
T
av 4a052ec99b Шаги схемы вынесены в свой каталог, и сверка их снова видит
- шаги PocketBase переехали из файла в пакет
  internal/adapter/repo/pocketbase/migrations, файл на шаг с именем
  зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go
- ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение
  указывало на несуществующий migrations/, и шаг гейта проходил зелёным при
  всякой правке схемы
- app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на
  константы унесла бы регистрацию, и хранилище поднялось бы без коллекций
2026-08-12 22:03:48 +03:00

15 KiB
Raw Blame History

Схема хранилища

Хранилище, коллекции, правило времени и идентификаторов.

Хранилище — встроенная PocketBase 0.39.10: она держит и базу, и файлы записей под одним каталогом данных. Ключ конфигурации — [storage] data_dir, умолчание data. В SQLite библиотека ходит через modernc.org/sqlite, поэтому CGO сборке не нужен.

Схему двигают шаги миграций PocketBase на Go, каталог internal/adapter/repo/pocketbase/migrations, файл на шаг и имя файла — имя шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме хранилища (pocketbase.New), прежде чем стартуют воркеры и сервер. Применённый шаг не переписывается — изменение только новым шагом: применённое хранилище считает по имени шага.

Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: шаг гейта сверяет изменённые шаги схемы с правкой этого документа по префиксу пути (docs/.docs.json, ключ migrations), а префикс наводится только на каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет репозитория берёт их оттуда.

Идентификаторы записей выдаёт хранилище — 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 — разные приговоры. В failed задачу переводит шаг, рассудивший об этой записи окончательно; в dead она уходит без такого суждения — мы повторяли и перестали. Ни один шаг конвейера в dead не переводит сам: это делает тот, кто захватил задачу с превышенным счётчиком.

Правила доступа обеих коллекций пусты, то есть перечислять и читать записи может только владелец панели. Проверено прогоном: анонимный запрос к /api/collections/*/records отвечает 403, к /api/logs, /api/backups, /api/settings и /api/crons401.

Представление данных

Чем физически лежит запись и что происходит при чтении и записи.

  • Расшифровка лежит целиком в поле transcription_text одной строкой. Запись длиной в час даёт десятки килобайт в одной ячейке; читается она целиком при каждом чтении задачи и при каждом захвате.
  • Аудио лежит в раскладке хранилища: data/storage/<коллекция>/<запись>/<имя> рядом с файлом атрибутов. Имя задаёт сервис — <uuid><расширение>; собственного суффикса хранилище не дописывает, потому что умолчание, строящее имя из имени отправителя, не применяется. Ни файлы, ни объекты в Object Storage не удаляются после завершения задачи: каталог и бакет растут неограниченно.
  • Файл отдаётся ссылкой /api/files/<коллекция>/<запись>/<имя>. Поле файла помечено защищённым шагом 202608120001, а правило просмотра коллекции пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт знание её идентификатора» — отменено задачей oidc-login 2026-08-12. Имя файла в хранилище в журнал не пишется по-прежнему: оно последняя часть ссылки.
  • Коллекция users заводится самой библиотекой, а шаг 202608120001 её сужает: создание записи разрешено только контексту обмена OIDC (@request.context = "oauth2"), вход по паролю и одноразовый код выключены. Без этого сужения закрытие API обходится двумя запросами — завести себе запись и войти паролем. Продление сессии закрыто слоем в приложении, а не настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
  • Захват задачи — один запрос с RETURNING, мимо записей коллекции. app.DB() направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени заведения и по ключу: время неуникально, и без ключа порядок обработки невоспроизводим.
  • Запись результата условна по признаку захвата. Шаг, чей захват за время работы достался другому, завершается без записи и без ответа отправителю.
  • Список колонок задан четырьмя местами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
Качество кодирования vorbis -q:a 4 adapter/converter/ffmpeg/ffmpeg.go
Жизнь приглашения завести владельца панели 30 минут умолчание PocketBase
Потолок размера одной записи 8 ГиБ entity.MaxRecordSize расчётный потолок в шесть часов с запасом на видео
Срок жизни сессии 7 суток pbrepo.SessionDuration, ставится при подъёме решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано
Потолок времени на вход у провайдера 10 минут controller/http/auth.go дольше носитель состояния не нужен
Таймаут обмена кода у провайдера 15 секунд там же молчащий провайдер иначе держит обработчик возврата открытым

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

Чего среди настроек нет: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.