Files
transcriber/docs/database.md
T
av b76f2d7c7e docs: раскладка переехала в .av-dev.toml, а расхождения документов сведены
- Перевод на канон 1 доделан: адреса служебного файла и имена скиллов
  переставлены в девяти местах прозы и кода, гейт зовёт три скрипта по новым
  путям, прежние docs/.docs.json и tasks/.tasks.json удалены.
- Сверка двумя агентами нашла четырнадцать расхождений, тринадцать сведены
  строками: число прогонов ревью и преамбула журнала дефектов, счёт capability,
  маршруты README, дубли инварианта захвата и кодов прогона, протухшие указатели
  записок разведки, маркер долга на переехавшем абзаце. Срок жизни сессии
  нормирует спека access, database.md на неё ссылается.
- Purpose спеки pipeline объявляет неописанным то, что в ней же и стоит; правка
  идёт изменением openspec, поэтому заведена задача pipeline-spec-purpose-drift.
2026-08-13 12:36:36 +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), прежде чем стартуют воркеры и сервер. Применённый шаг не переписывается — изменение только новым шагом: применённое хранилище считает по имени шага.

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

Идентификаторы записей выдаёт хранилище — 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/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
Качество кодирования 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 тоже нет — ни одного.