хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
+107
-59
@@ -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 тоже нет — ни одного.
|
||||
|
||||
Reference in New Issue
Block a user