хранилище, файлы записей и очередь переведены на встроенную 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
@@ -0,0 +1,63 @@
# Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
## Решение
Поле файла в хранилище **не помечается защищённым**: ссылка
`/api/files/<коллекция>/<запись>/<имя>` работает без токена, и право пройти по
ней даёт знание самой ссылки. Взамен имя файла в хранилище **не пишется в журнал
ни в каком виде** — ни на успешном пути, ни в тексте отказа.
## Почему
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для
этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному
файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
> Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
> прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
> `GET /api/status/:id`, а не ниже.
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это
условие сломал, и вот чем:
> Изъятие выписано под путь на диске: `data/files/<uuid>.ogg` читателю журнала
> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки
> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала
> бы бессрочным ключом к чужому аудио.
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object
Storage — тот несёт полный URL объекта.
Отсюда вторая половина решения, без которой первая недопустима: **отказы
обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем;
прослеживаемость от этого не страдает.
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть
файлы токеном предложат снова, и без записанной причины предложение выглядит
бесплатным.
## Последствия
- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее
приложение получает файл без отдельного механизма выдачи токенов.
- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему
только расширение, а не имя.
- `` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не
удаляются вовсе. Утечка возможна не только журналом — любой будущий экран,
показывающий ссылку, наследует это свойство.
- `` появилась норма, которую держит не построение, а внимание: всякий новый
отказ хранилища надо обрывать руками. Норму сторожат требование capability
`storage` и проверка журнала, но компилятор — нет.
- `` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У
выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK
(`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает.
- Решение действует до разграничения доступа: задачи `oidc-login` и
`record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
записью.
@@ -0,0 +1,50 @@
# Каталог данных задаётся одним ключом `[storage] data_dir`
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Ключи конфигурации: два пути заменяются одним каталогом»
## Решение
Ключи `[database] path` и `[storage] path` уходят. Вместо них — один
`[storage] data_dir` со значением `data`: база и файлы записей лежат под одним
каталогом, и по-другому хранилище не умеет.
Выбор сделан человеком 2026-08-12 из трёх названных вариантов.
## Почему
Имя ключа конфигурации проект объявил необратимым
([../../CLAUDE.md](../../CLAUDE.md), «Работа»): переименование правится не в
одном файле, а в конфигурации на сервере и в выкладке, и молча ломает запуск.
Поэтому выбор ушёл человеку, а не был принят по ходу.
Цитата источника о цене каждого варианта:
> - `[storage] data_dir` — **выбрано**. Ключ назван по назначению, как названы и
> сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
> этом уже занято capability, но в конфигурации оно значит ровно то же — где
> лежат данные;
> - `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
> вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует
> второго необратимого переименования;
> - `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
> каталог на диске, и секция с таким именем читается как «настройки каталога»,
> а не «настройки хранилища».
Запись попадает в журнал по **дорогому откату**: переименование ключа стоит
правки конфигурации на сервере и в выкладке, а ошибка проявляется отказом старта.
## Последствия
- `+` имя ключа не называет поставщика, и смена библиотеки хранилища второго
необратимого переименования не потребует.
- `+` каталог данных один, и запрет «боевой каталог не трогать» покрывает и базу,
и записи одной строкой.
- `` слово `storage` в проекте теперь значит три вещи: capability, само
хранилище и секцию конфигурации. Поле записи о файле от этого переименовано в
`location` — чтобы смыслов было три, а не четыре.
- `` прежние конфигурации несовместимы: сервис на старом `config.toml`
поднимется на умолчании `data`, а не на прежних путях. Данные при этом не
переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не
нулевой при переносе.
+2
View File
@@ -32,6 +32,8 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
+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`. Коллектор был бы процессом, которого в
+15 -5
View File
@@ -15,6 +15,9 @@
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи.
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
хронологией не является: порядок берут по колонке времени с ключом.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
@@ -43,14 +46,21 @@
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
кодом — ради панели владельца: правка руками не должна заводить состояние,
которого конвейер не знает. Цена названа: шестое состояние потребует нового
шага схемы.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
- Миграции — goose (`migrations/`): SQL-файлы для DDL; Go-миграции
(`goose.AddMigrationContext`) — когда нужен код (генерация id, заполнение
задним числом). При изменении структуры обновляем схему
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Добавляя колонку, соблюдай инвариант «Новая колонка правится во всех четырёх
местах» — [CLAUDE.md](../../CLAUDE.md), «Инварианты».
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки
невоспроизводимым.
+7 -2
View File
@@ -202,8 +202,13 @@ Object Storage, скачивание файла из Telegram и опрос оп
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics`
попадают в лог наравне с остальными.
Расхождения здесь больше нет: слой журналирования запросов свой,
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
по которому разбирают отказы, эта таблица не попадает.
## Безопасность: что не логируем
+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 тоже нет — ни одного.
+1
View File
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
| 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase |
+98
View File
@@ -0,0 +1,98 @@
# PocketBase: умолчания, которые ломают штатный сценарий
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
`&core.FileField{MaxSize: 0}` читается библиотекой как её собственное умолчание:
```
core/field_file.go:28 const DefaultFileFieldMaxSize int64 = 5 << 20
core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
```
Проверено укладкой: файл в 6 МиБ отвергается на сохранении записи —
`the maximum allowed file size is 5242880 bytes`. Прогон через боевой роутер дал
границу дословно:
| тело запроса | ответ |
| --- | --- |
| 4 194 304 байта | `201` |
| 5 238 784 байта | `201` |
| 5 246 976 байт | `500` |
| 34 603 008 байт | `413` |
**Цена для сервиса:** 5 МиБ — это примерно 5,5 минут mp3 при 128 кбит/с. Отвергалась
бы не только длинная запись на приёме: результат конвертации в ogg переваливает
тот же порог примерно на пятой минуте, и **уже принятая** задача исчерпывала бы
попытки на шаге конвертации.
## Тело запроса режется на 32 МиБ раньше обработчика
`apis/base.go:36` вешает `BodyLimit(DefaultMaxBodySize)` на **корневой** роутер,
то есть и на чужие маршруты; `apis/middlewares_body_limit.go:14`
`const DefaultMaxBodySize int64 = 32 << 20`. Ответ `413` уходит мимо обработчика,
без строки в журнале приёма (последняя строка таблицы выше).
Снимается на маршруте: `.Bind(apis.BodyLimit(<своё число>))`.
## Таймаут чтения запроса — пять минут
`apis/serve.go:151` ставит `ReadTimeout: 5 * time.Minute`. Заливка шестичасовой
записи по медленному каналу его переживает: соединение рвётся на середине.
Снимается в хуке `OnServe``se.Server.ReadTimeout = 0`.
## Суффикс к имени файла дописывает конструктор, а не укладка
Первая записка наблюдала `sample.ogg → sample_uztrv6wvz3.ogg` и читала это как
свойство хранилища. Наблюдение верно **только когда имя строит сама библиотека**:
десять случайных знаков добавляет `normalizeName`, вызываемый из
`filesystem.NewFileFrom*`. Имя, положенное в поле `File.Name` после
конструктора, ложится на диск дословно:
```
задано 11111111-2222-3333-4444-555555555555.mp3
на диске 11111111-2222-3333-4444-555555555555.mp3
```
**Цена:** тот, кто задаёт имя сам, не получает от суффикса никакой
неугадываемости — и защищать ссылку на файл ему приходится другим.
## Хук правки записи не различает, кто пишет
`app.OnRecordUpdate(<коллекция>)` — событие **модели**: оно срабатывает на каждом
`app.Save`, включая сохранение из собственного кода. Хук, написанный «для
панели», правил записи конвейера: проверено прогоном — задержка, поставленная
шагом вместе со сменой состояния, обнулялась тем же сохранением.
Различает источник `app.OnRecordUpdateRequest(<коллекция>)`: оно поднимается
только на правку запросом, а код, пишущий мимо HTTP-слоя, под него не попадает.
## Приглашение завести владельца панели живёт полчаса
`apis/installer.go:31``systemSuperuser.NewStaticAuthToken(30 * time.Minute)`;
печатается только пока владельца нет (`needInstallerSuperuser`). Проверено
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет.
## Чего эта записка не узнала
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
шестичасовой записи с запасом на видео, а не замером: настоящего распределения
длин у сервиса нет.
- **Как ведёт себя укладка файла в несколько гигабайт.** Самая длинная проверенная
запись — 9,6 МБ (десять минут mp3). Потоковую укладку это подтверждает, предел
— нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой.
+4
View File
@@ -69,6 +69,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в
`sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов.
*Уточнено 2026-08-12:* суффикс дописывает конструктор имени, а не укладка. Имя,
заданное после конструктора, ложится на диск дословно — см.
[pocketbase-defaults.md](pocketbase-defaults.md).
Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться
на файл, уже лежащий на диске мимо её каталога, нет.
+22
View File
@@ -197,6 +197,28 @@ API и имя не откатываются обратной правкой по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
выдумывать его задним числом нельзя.
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0`
её требует PocketBase, — а `Dockerfile` продолжал собирать на `golang:1.24-alpine`
с `GOTOOLCHAIN=local`. `task image` упал бы на шаге сборки: выкладки задачи
`pocketbase-storage` не существовало бы вовсе.
**Почему не поймали.** Все шесть проходов ревью и весь гейт видели зелёное:
`go build ./...` идёт на хостовом Go, а образ не собирает **ни один шаг гейта**.
Расхождение выглядело согласованным ещё и потому, что `CLAUDE.md` и `README.md`
обещали Go 1.24 — то есть три места из четырёх говорили одно и то же, и неверными
были именно они.
Нашлось не проходом, а триажем — при проверке чужих починок на месте, когда он
собрал образ руками. То есть поймано случайным свойством прогона, а не
устройством конвейера: проверь триаж починки чтением, дефект уехал бы в мердж.
**Чем чинится на будущее.** Сборка образа гейтом не проверяется намеренно —
дорого. Дешёвая замена: шаг, сверяющий версию сборщика в `Dockerfile` с
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
ревью.
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
+33 -26
View File
@@ -85,16 +85,27 @@ Telegram отправителю.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** `filepath.Join(cfg.Storage.Path, fileId + ext)`, где
`fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через
`filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext`
режет по последней точке и не пропускает разделитель каталогов, но это
единственное, что стоит между входом и именем файла.
- **Путь на диске** выбирает хранилище:
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис**
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
отправителя, не применяется: имя отправителя в хранилище не попадает.
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
- **Каталог** один и плоский: `data/files` целиком, вложенности нет.
- **Идентификатор задачи** — UUID v4. Он же единственное, что защищает
`GET /api/status/:id`.
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; проверено прогоном — записи отдают
`403`, служебные разделы `401`.
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
@@ -103,15 +114,7 @@ Telegram отправителю.
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
плоский каталог — раскладка `data/files` меняется, и это необратимо.
- **Раскладку выбирает PocketBase** (`pocketbase-storage`), и плоского каталога
не остаётся вовсе: файл ложится в
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Имя, данное отправителем, в путь при этом попадает — сегодня
от него берётся только расширение. Файл уходит не с диска напрямую, а по ссылке
вида `/api/files/<коллекция>/<запись>/<имя>`; закрытым он становится, только если
поле помечено защищённым, и тогда нужен отдельный файловый токен. Замер —
[research/pocketbase.md](research/pocketbase.md).
плоский каталог — раскладка каталога данных меняется, и это необратимо.
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
идентификатора задачи: имя, данное пользователем, в него не попадает.
@@ -180,10 +183,14 @@ Telegram отправителю.
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу.
6. **Пароль суперпользователя панели** (`pocketbase-storage`). Открывает все
записи, все файлы и всех пользователей разом, то есть стоит вровень с самым
чувствительным из списка выше. Второй секрет после токенов пользователей,
который лежит **не в конфигурации**: его отпечаток хранит сама база.
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
пользователей разом, то есть стоит вровень с самым чувствительным из списка
выше. Второй секрет после токенов пользователей, который лежит **не в
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
по приглашению, которое сервис печатает в журнал при первом запуске. У
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
читателю навсегда.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
@@ -194,10 +201,10 @@ Telegram отправителю.
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. Оно стоит в собственном имени файла на диске, а путь к файлу логируется.
Читает этот журнал владелец сервиса. Нормализация расширения на диске — отдельная
работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется
решением человека.
`08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
файла объявлен необратимым и меняется решением человека.
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
`transcriber_input_file_size_bytes`, `source_format` у
@@ -236,7 +243,7 @@ Telegram отправителю.
замер: `research/` пуст, потолок длины стоит открытым вопросом
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
Authelia. Рост каталога `data/files` при этом ничем не наблюдается —
Authelia. Рост каталога данных при этом ничем не наблюдается —
открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению.