Files
transcriber/docs/database.md
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

324 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
Хранилище — **встроенная 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](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
репозитория берёт их оттуда.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
хранилище, задаёт сервис, и это `<uuid><расширение>`.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит
[architecture.md](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`, `telegram`, `unknown` |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `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 | Проставляет хранилище |
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
паузе и сроку протухания.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
хранилище, и принятого человеком файла не найти было ничем.
**Остановка — признак, а не рубеж.** Прежние состояния `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` в
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому
сам приём, а не схема.
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
файловой записи равнялось праву скачать чужое аудио.
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
вместо нашего отказа с причиной.
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons``401`.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит отдельной строкой `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](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
что условие проверяется тем же запросом, что и сам захват.
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
перечень перестал расти с моделью. Правило правки и его серьёзность —
инвариант в [CLAUDE.md](../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` | первая |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 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](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.