- CLAUDE.md, «Язык»: ссылаться можно на конкретную запись или на весь корпус разом, но не на их количество — число протухает молча, машина его не считает. Изъятие названо: неизменное число и историческое в записи о прошлом остаются. - Сняты счёты capability, прогонов ревью, типизированных ошибок, воркеров, сверок документов и правил линтера в docs/, спеке pipeline и CLAUDE.md. - Заодно исправлено то, что этот же счёт и скрывал: типизированных ошибок три, а не две — LostAcquisitionError был потерян из перечня.
170 lines
16 KiB
Markdown
170 lines
16 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`
|
||
|
||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
||
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](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
||
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
||
потребует нового шага.
|
||
|
||
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
||
может только владелец панели. Проверено прогоном: анонимный запрос к
|
||
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
|
||
`/api/settings` и `/api/crons` — `401`.
|
||
|
||
## Представление данных
|
||
|
||
Чем физически лежит запись и что происходит при чтении и записи.
|
||
|
||
- **Расшифровка лежит целиком в поле `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](../CLAUDE.md), «Инварианты» (major);
|
||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
||
что условие проверяется тем же запросом, что и сам захват.
|
||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
||
серьёзность (critical/major) — инвариант в [CLAUDE.md](../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` | — |
|
||
| Срок ожидания 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 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||
|
||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
||
нагрузке объявлена вне модели угроз.
|
||
|
||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||
файлов и объектов нет вовсе. Таймаутов у
|
||
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
|