хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
+107 -59
View File
@@ -1,101 +1,149 @@
# Схема хранилища
СУБД, миграции, правило времени и идентификаторов.
Хранилище, коллекции, правило времени и идентификаторов.
СУБД — SQLite, драйвер `mattn/go-sqlite3` (нужен CGO). Запросы строит
`doug-martin/goqu` с диалектом `sqlite3`. Миграции — `pressly/goose`, каталог
`migrations/`, вшит в бинарник через `//go:embed migrations/*.sql` в `main.go` и
накатывается при старте. Новый файл достаточно положить в каталог.
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
CGO сборке не нужен.
**Идентификаторы** — UUID v4 строкой.
Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
переписывается — изменение только новым шагом: применённое хранилище считает по
имени файла.
**Время** — локальная зона процесса, UTC не навязан. Колонки `created_at` и
`updated_at` проставляет приложение, а не СУБД; умолчание `CURRENT_TIMESTAMP`
стоит только у `files.created_at`.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
хранилище, задаёт сервис, и это `<uuid><расширение>`.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит
[architecture.md](architecture.md), «Единые точки проекта».
Переход на PocketBase запланирован, но не начат — здесь описано сегодняшнее
состояние. Открытые вопросы перехода — в
[architecture.md](architecture.md), раздел «Открытые вопросы».
## Таблицы
## Коллекции
### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — три разные записи.
| Колонка | Тип | Что |
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | UUID файла |
| `storage` | TEXT | `local` или `s3` |
| `file_name` | TEXT | Имя в хранилище: UUID с расширением |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл; пусто у копии в Object Storage |
| `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
| `size` | INTEGER | Размер в байтах |
| `created_at` | DATETIME | Умолчание `CURRENT_TIMESTAMP` |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
### `transcribe_jobs`
Задача расшифровки и она же очередь.
| Колонка | Тип | Что |
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | UUID задачи |
| `state` | TEXT | `created`, `converted`, `transcribe`, `done`, `failed` |
| `source` | TEXT | `api`, `telegram`, `unknown`; умолчание `unknown` |
| `file_id` | TEXT FK`files.id` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `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` | TEXT | Результат распознавания |
| `is_error` | BOOLEAN | Задача с `1` из выборки исключена навсегда |
| `transcription_text` | editor | Результат распознавания |
| `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `created_at`, `updated_at` | DATETIME | Проставляет приложение |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Индексов, кроме первичных ключей, нет. Выборка воркера идёт полным перебором по
`state`, `is_error`, `delay_time` и `acquire_time`.
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
этот один.
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам: это делает тот, кто захватил задачу с превышенным счётчиком.
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
может только владелец панели. Проверено прогоном: анонимный запрос к
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
`/api/settings` и `/api/crons``401`.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в колонке `transcription_text`** одной строкой.
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом `GetByID` и при каждом захвате задачи воркером.
- **Аудио в базе не лежит.** На диске — каталог `data/files`, плоский, имя файла
равно UUID с расширением. Ни файлы, ни объекты в Object Storage не удаляются
после завершения задачи: каталог и бакет растут неограниченно.
- **Захват задачи — два запроса подряд, не транзакция.** Сперва `UPDATE …
WHERE id = (SELECT … LIMIT 1)` проставляет `acquisition_id`, затем отдельный
`SELECT … WHERE acquisition_id = ?` читает строку. Репозиторий сверяет число
затронутых строк с ожидаемым, но между запросами задачу может перехватить
другой воркер с тем же значением — на одном процессе это не наблюдалось.
- **Список колонок задан не одним местом** — четырьмя запросами файла
`internal/adapter/repo/sqlite/transcript_job_repo.go`. Правило правки всех
четырёх и его severity — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
целиком при каждом чтении задачи и при каждом захвате.
- **Аудио лежит в раскладке хранилища:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
каталог и бакет растут неограниченно.
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
хранилище **в журнал не пишется** — оно последняя часть ссылки.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим.
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
работы достался другому, завершается без записи и без ответа отправителю.
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
в Object Storage: отказ SDK несёт полный URL объекта.
## Настройки с числовым значением
| Настройка | Значение | Где |
| --- | --- | --- |
| Срок захвата, конвертация и распознавание | 1 час | `service/transcribe.go`, вызовы `findJob` |
| Срок захвата, проверка операции | 24 часа | там же |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` |
| Задержка между проверками операции | 5 секунд | там же |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` |
| Память под multipart-загрузку | 32 МиБ | `main.go`, `router.MaxMultipartMemory` |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` |
| Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- |
| Предел попыток | 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` | расчётный потолок в шесть часов с запасом на видео |
Чего среди настроек **нет**: режим журналирования SQLite не задан (значение по
умолчанию, не WAL), таймаут занятости не задан, размер пула соединений не задан,
срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и
SpeechKit тоже нет — ни одного.
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.