Files
transcriber/docs/database.md
T
av 8af8ec2e54 у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
2026-08-14 12:18:11 +03:00

18 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), прежде чем стартуют воркеры и сервер. Применённый шаг не переписывается — изменение только новым шагом: применённое хранилище считает по имени шага.

Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: шаг гейта сверяет изменённые шаги схемы с правкой этого документа по префиксу пути, а префикс наводится только на каталог. Где этот префикс задан — 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
owner relation → users Владелец файла; пусто у файлов записи, принятой ботом
location select local или s3
object_key TEXT Ключ объекта; пусто у местной копии
size INTEGER Размер в байтах
created, updated DATETIME Проставляет хранилище

Поле названо location, а не storage: последним словом зовут само хранилище и capability, и третий смысл развёл бы одно слово по разным вещам.

transcribe_jobs

Задача расшифровки и она же очередь.

Поле Тип Что
id TEXT PK Идентификатор записи, выдаёт хранилище
owner relation → users Владелец записи; пусто у записей, принятых ботом
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, «Число попыток и состояние «мертва»». Схеме принадлежит только закрытость перечня: шестое состояние потребует нового шага.

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

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

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

Правила доступа задач пусты, то есть перечислять и читать их может только владелец панели. Проверено прогоном: анонимный запрос к /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() направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени заведения и по ключу: время неуникально, и без ключа порядок обработки невоспроизводим.
  • Запись результата условна по признаку захвата — инвариант «Результат пишет только держатель захвата» в 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 тоже нет — ни одного.