хранилище, файлы записей и очередь переведены на встроенную 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
+35 -36
View File
@@ -8,16 +8,19 @@
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы».
Заведены две capability, и каждая описана частично:
Заведены три capability:
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
нормируют проверки, написанные задачей `http-handler-tests-never-green`
2026-08-11;
- [pipeline](../openspec/specs/pipeline/spec.md) — **только пустой прогон
воркера**: задача `errors-as-instead-of-typecast` 2026-08-11. Переходы
состояний, захват и срок его протухания, отмена контекста посреди шага в неё
**не** переехали и остаются долгом; что именно не описано, перечисляет раздел
`Purpose` самой спеки.
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
коде. Задача, которая его трогает, дописывает спеку своей capability.
@@ -26,17 +29,16 @@
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу
запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в
день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим —
решено 2026-08-11,
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку
очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md).
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
достаётся снова по истечении срока захвата и проходит шаг заново.
- **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
`main.go`.
## Компоненты
@@ -54,12 +56,15 @@
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/sqlite` | Задачи и файлы, запросы через goqu |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Три
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду.
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
а тот, кто её захватил.
## Внешние границы и форматы
@@ -91,7 +96,7 @@
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
@@ -107,7 +112,9 @@
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` |
@@ -131,14 +138,9 @@
## Открытые вопросы
- **Хранилище.** PocketBase заменяет SQLite с goqu и goose, файлы переезжают в её
раскладку на диске — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), замер панели
в [research/pocketbase.md](research/pocketbase.md). Требование CGO этим
снимается. Чем становится конвейер задач, решено 2026-08-11 — см. «Очередь»
ниже. Данные не переносим — начинаем с чистого листа.
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
обрабатывает PocketBase, а не наш код (тот же ADR). Не решено, где живёт сессия
обрабатывает PocketBase, а не наш код
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
и как связываются пользователь Telegram и пользователь веба. Панель
администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя.
@@ -168,25 +170,22 @@
копится — записи о потреблении или счётчики — решает задача
`usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается.
2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. После переезда на PocketBase не решено, хватит ли копировать
её каталог файлами, или приложению нужна команда выгрузки: база под нагрузкой
копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase
есть — берём мы его или нет, тоже не решено.
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
или нет, тоже не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Очередь.** Модель очереди решена 2026-08-11: остаётся своей таблицей и
становится коллекцией PocketBase, захват сворачивается в один запрос с
`RETURNING`, число попыток ложится колонкой, а исчерпавшая их задача переходит
в состояние «мертва» вместо `is_error = 1`
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)). Пишет это
`pocketbase-storage` тем же заходом, что и хранилище. Не решено, отказываться
ли от холостого опроса: три воркера дают 259 200 запросов в сутки при нагрузке
в единицы записей в день, и во что это обходится, никто не мерил.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
это обходится, никто не мерил.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в