Files
transcriber/docs/database.md
T
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00

36 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

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

Поле Тип Что
id TEXT PK Идентификатор записи, выдаёт хранилище
file file Сам файл
owner relation → users Владелец файла; пустого значения не принимает
location select local или s3
object_key TEXT Ключ объекта; заведён прежним шагом и новым путём не заполняется
size INTEGER Размер в байтах
format TEXT Расширение без точки, в нижнем регистре
duration_ms INTEGER Длительность, если её удалось прочитать
created, updated DATETIME Проставляет хранилище

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

audio_records

Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на приложения лежат здесь; содержимое — по ссылкам, отдельными строками.

Поле Тип Что
id TEXT PK Идентификатор записи, выдаёт хранилище
owner relation → users Владелец записи; пустого значения не принимает
source select api, unknown; значение telegram осталось историческим — вход убран, новых записей с ним не появляется
title, brief TEXT Заголовок и краткое описание: читаются вместе со списком
original_filename TEXT ≤ 255 Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки
duration_ms INTEGER ≥ 0 Длительность принятого, миллисекунды; ставит приём и всегда
size_bytes INTEGER ≥ 0 Размер принятого, байты
state select Рубеж: uploaded, normalized, submitted, transcribed, done; перечень закрыт схемой
state_entered_at DATETIME Время входа в рубеж — сторож застревания
halted_at DATETIME Признак остановки; рубеж при ней не стирается
halt_reason select step_failed, attempts_exhausted, stuck
error_text TEXT Текст ошибки, машинный
acquisition_id TEXT Признак этого захвата, уникальный для каждого
acquire_expires_at DATETIME Срок протухания захвата; приезжает с рубежом
delay_time DATETIME Не брать запись раньше этого времени
attempts INTEGER ≥ 0 Число отказов: растёт при захвате, обнуляется на шаге без отказа и на откладывании
original_file relation → files Принятая копия
normalized_file relation → files Копия, приведённая к рабочему формату
transcript_text, literary_text relation → texts Тексты записи
structure relation → structures Структура реплик
recognition relation → recognitions Попытка распознавания
topics relation → topics, до 5 Темы записи
tg_chat_id INTEGER Адресат ответа у записи убранного входа; кодом не читается
tg_reply_message_id INTEGER Ответное сообщение у неё же; кодом не читается
created, updated DATETIME Проставляет хранилище

Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и сроку протухания идёт выборка захвата. Два других завела страница списка приложения: (owner, created DESC, id DESC) под страницу «новыми сверху» и (owner, state, halted_at) под отбор тремя состояниями.

Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает ничем. Замер на задаче json-api-for-spa 2026-08-15: без своего индекса страница сканировала таблицу целиком и досортировывала результат во временном дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы владельца в двадцать-тридцать раз — при неизменных сорока его собственных записях. Цена росла с чужими записями, потому что сервис объявлен архивом и хранит их бессрочно.

Имя файла и заголовок — разные колонки. Заголовок несёт название, которое дал человек либо посчитала языковая модель; имя файла — то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя файла в хранилище и в журнал оно по-прежнему не идёт.

Длительность и размер лежат и на записи, и на её файле, и равенство между ними не поддерживается никем — намеренно. На записи снимок принятого, взятый приёмом один раз; на файле — величины нынешней копии файла. Уточнение длительности меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому, что показываются в списке, а список читается без содержимого. Решение владельца от 2026-08-15.

«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15. Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём. Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными метаданными отвергается отказом и не заводится вовсе.

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

Остановка — признак, а не рубеж. Прежние состояния failed и dead схлопнуты в halted_at с причиной: обе восстанавливаются одинаково — снятием признака, — и различие между ними перестало быть структурным. Рубеж при остановке сохраняется, поэтому запись продолжает с места остановки.

Сторожей двое. attempts ограничивает повторы внутри шага, state_entered_at — застревание. Прежде обе обязанности несло одно число, и не справлялось ни с одной.

texts

Поле Тип Что
record relation → audio_records Чья это расшифровка
kind select transcript или literary
contents editor Сам текст

Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки. Поле зовётся kind, а не format: словом format в этой же схеме зовут формат файла.

structures

Поле Тип Что
record relation → audio_records Чья это структура
version INTEGER Версия вида разбора
contents JSON Реплики со временем

Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор сохранённого ответа изменится раньше, чем архив пересчитают.

recognitions

Попытка распознавания у внешнего провайдера — всё, что зависит от него.

Поле Тип Что
record relation → audio_records Чья это попытка
provider, model TEXT Кем и какой моделью считано
external_id TEXT Идентификатор операции у провайдера
source_uri TEXT Адрес, по которому провайдер читает аудио
payload file, защищённое Сырой ответ провайдера целиком
started_at, finished_at DATETIME Границы операции

Сырой ответ лежит вложением, а не колонкой. Шаг опроса читает эту строку раз в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую запись ехал бы в память при каждом опросе. Хранится он потому, что результат операции у провайдера не переспрашивается.

Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.

record_events

Поле Тип Что
record relation → audio_records Чьё это событие
origin select pipeline или human
step TEXT Имя шага
outcome select done, failed, halted, resumed
outcome_text TEXT Причина, если она есть
duration_ms INTEGER Сколько шаг занял

Колонка текста зовётся outcome_text, а не error_text: последнее имя названо поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант двусмысленным.

Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить, что делать дальше.

topics

Поле Тип Что
owner relation → users Чей это словарь
name TEXT Название темы

Пара «владелец и название» уникальна: словарь тем свой у каждого человека. Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не платила вторым необратимым шагом схемы.

Чего в схеме больше нет

Коллекция transcribe_jobs удалена шагом 202608140002. Данных под ней не было: сервис на сервере остановлен, а прежние записи удалены решением владельца 2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция висела бы в панели вторым домом для понятия, которого больше нет.

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

Колонки tg_chat_id и tg_reply_message_id остались от убранного входа и кодом больше не читаются. Из схемы они не убираются: заводили их применённые шаги 202608110001 и 202608140002, а применённый шаг не переписывается.

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

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

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

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

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

  • Расшифровка лежит отдельной строкой texts, а не колонкой записи. Захват её не тянет вовсе: он возвращает идентификатор и признак своего захвата, а колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той же строки и читалась при каждом опросе очереди.
  • Аудио лежит в раскладке хранилища: 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. Здесь названо потому, что условие проверяется тем же запросом, что и сам захват.
  • Список колонок задан двумя местамиapplyOwnedByPipeline вместе с applyToRecord и recordToAudioRecord, — плюс шагом схемы. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и перечень перестал расти с моделью. Правило правки и его серьёзность — инвариант в CLAUDE.md, «Инварианты»; сверку держат правила internal/archrules.
  • Перечень рубежей объявлен одним дескрипторомinternal/entity/stage.go. Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя: рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту проекта не пишется в журнал и не считается в метрику.
  • Отказ хранилища наружу не выходит дословно. Он несёт ключ файла целиком, а ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают свой текст с идентификатором записи, а цепочку %w обрывают. То же у выгрузки в Object Storage: отказ SDK несёт полный URL объекта.

Настройки с числовым значением

Настройка Значение Где Откуда число
Предел отказов 5 service/transcribe.go обычное умолчание, не замер
Пауза перед повтором 2^(отказ−1) с, потолок 5 минут там же то же
Срок захвата, приведение 8 часов entity/stage.go потолок записи 6 часов плюс запас
Срок захвата, отправка на распознавание 8 часов там же то же
Срок захвата, опрос операции 1 час там же опрос идёт секунды
Срок захвата, завершение 1 час там же запись текста и ответ идут секунды
Число воркеров конвейера 3 конфиг, [pipeline] workers решение владельца; ноль — законное значение
Предел простоя, своя работа 60 минут конфиг, [pipeline] own_work_limit_minutes решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число меньше времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже
Предел простоя, чужая операция 1440 минут конфиг, [pipeline] foreign_work_limit_minutes сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого
Версия вида структуры реплик 1 entity.StructureVersion первая
Умолчание размера страницы списка 30 controller/http.DefaultPageLimit столько помещается на экран телефона без прокрутки в два экрана
Потолок размера страницы списка 100 controller/http.MaxPageLimit против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром
Ограничитель частоты под /app/ 120 запросов за 60 секунд controller/http.appRateMaxRequests, appRateWindowSec сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи
Доля бюджета под опрос карточки 1/8 controller/http.pollBudgetShare опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли выводится объявляемая частота опроса, и своей константы у неё нет
Потолок длины имени файла отправителя 255 знаков entity.MaxOriginalFilenameLen предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт
Потолок длины расширения 32 знака service/transcribe.go, maxExtLen сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а x. с четырьмястами знаками роняет заведение временного файла
Потолок тем на запись 5 entity.MaxTopicsPerRecord решение владельца: без него часовой разговор даёт два десятка тем
Потолок сохранённого ответа провайдера 256 МиБ шаг 202608140002 ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих
Потолок структуры реплик 16 МиБ там же шестичасовой разговор даёт порядка мегабайта текста с временем
Задержка перед первой проверкой операции 10 секунд service/transcribe.go как было
Задержка между проверками операции 5 секунд там же как было
Пауза воркера между прогонами 1 секунда controller/worker/worker.go как было
Таймаут мягкой остановки 5 секунд конфиг, [server] shutdown_timeout
Таймаут жёсткой остановки 20 секунд конфиг, [server] force_shutdown_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 секунд там же молчащий провайдер иначе держит обработчик возврата открытым

У сторожа простоя есть второй потолок, и он не тот, что в настройке. Предел простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит по нехватке памяти или docker kill, — запись невидима сторожу до истечения срока захвата её рубежа, то есть восьми часов у приведения и отправки. Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и остановка «застряла» наступает только после него. Мягкая остановка сюда не подпадает: она снимает захват сама.

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

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