- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
13 KiB
Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
Хранилище — встроенная PocketBase 0.39.10: она держит и базу, и файлы
записей под одним каталогом данных. Ключ конфигурации — [storage] data_dir,
умолчание data. В SQLite библиотека ходит через modernc.org/sqlite, поэтому
CGO сборке не нужен.
Схему двигают шаги миграций PocketBase на Go, каталог
internal/adapter/repo/pocketbase, файл шага — migrations.go. Шаг
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
(pocketbase.New), прежде чем стартуют воркеры и сервер. Применённый шаг не
переписывается — изменение только новым шагом: применённое хранилище считает по
имени файла.
Идентификаторы записей выдаёт хранилище — 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/crons — 401.
Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- Расшифровка лежит целиком в поле
transcription_textодной строкой. Запись длиной в час даёт десятки килобайт в одной ячейке; читается она целиком при каждом чтении задачи и при каждом захвате. - Аудио лежит в раскладке хранилища:
data/storage/<коллекция>/<запись>/<имя>рядом с файлом атрибутов. Имя задаёт сервис —<uuid><расширение>; собственного суффикса хранилище не дописывает, потому что умолчание, строящее имя из имени отправителя, не применяется. Ни файлы, ни объекты в Object Storage не удаляются после завершения задачи: каталог и бакет растут неограниченно. - Файл отдаётся ссылкой
/api/files/<коллекция>/<запись>/<имя>. Поле файла не помечено защищённым: право прочитать запись даёт знание её идентификатора, и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в хранилище в журнал не пишется — оно последняя часть ссылки. - Захват задачи — один запрос с
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 |
расчётный потолок в шесть часов с запасом на видео |
Потолок размера назван числом в двух местах сразу — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по медленному каналу переживают любой фиксированный, а стойкость к целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек нет: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.