- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
324 lines
30 KiB
Markdown
324 lines
30 KiB
Markdown
# Схема хранилища
|
||
|
||
Хранилище, коллекции, правило времени и идентификаторов.
|
||
|
||
Хранилище — **встроенная 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 тоже нет — ни одного.
|