diff --git a/docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md b/docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md new file mode 100644 index 0000000..96b23b0 --- /dev/null +++ b/docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md @@ -0,0 +1,66 @@ +# Очередь остаётся своей таблицей, но коллекцией PocketBase + +- **Дата:** 2026-08-11 +- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка + разведки `job-queue-choice` + +## Решение + +Очередь задач остаётся своей таблицей и становится коллекцией PocketBase: захват +идёт одним запросом с `RETURNING`, число попыток лежит колонкой, нарастающая +пауза выражается существующим `delay_time`, а исчерпавшая попытки задача +переходит в состояние «мертва» вместо сегодняшнего `is_error = 1`. Готовую +библиотеку очереди не берём. + +## Почему + +Разведка искала готовую очередь и нашла, что для PocketBase её нет: + +> Единственная очередь в списке — `pocketbase-queue`, написана на TypeScript и +> работает из JS-хуков; из Go её не подключить. + +Отсюда разрез, который и решил дело: + +> выбор идёт не между готовым и своим, а между **своим в коллекции PocketBase** и +> **чужой очередью, живущей рядом с PocketBase и мимо её панели**. River и goqite +> про PocketBase не знают. + +Главный довод в пользу чужой библиотеки — транзакционный захват — снялся +замером: + +> движок за `modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём +> есть, и на трёх горутинах разом запись получила **ровно одна**. Это снимает +> главный довод в пользу чужой библиотеки: транзакционность захвата покупается +> одной строкой запроса, а не новой зависимостью. + +Отвергнуты два кандидата, и оба с названной ценой: + +> **River с драйвером SQLite** — покупает повторы, счётчик и мёртвых готовыми, но +> выносит очередь из панели PocketBase, ради которой хранилище и переезжает, и +> переписывает конвейер в цепочку задач. +> +> **goqite** — не отвечает ни на один из трёх вопросов задачи целиком, а его +> предел выдач молча теряет запись. + +Запись попадает в журнал как **намеренный отказ от очевидного подхода**: взять +готовую библиотеку вместо своего кода — первое, что предлагают на такой вопрос, и +без записанной причины его предложат снова. Принцип «очередь таблицей» из +[../architecture.md](../architecture.md) этим решением подтверждён, а не +пересмотрен, поэтому парного статуса «заменено на» никакая запись не получает. + +## Последствия + +- `+` очередь видна и правится в панели администратора: мёртвая задача повторяется + снятием состояния, а не запросом в консоли сервера. Ровно за это и куплен + перевод хранилища на PocketBase + ([ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). +- `+` ни одной новой зависимости: River добавил бы 46 пакетов в сборку, goqite — 3. +- `+` захват перестаёт быть двумя запросами без транзакции, и это перестаёт быть + местом, которое держится на том, что три воркера читают три разных состояния. +- `−` повторы, счётчик попыток и очередь мёртвых пишем сами, и корректность + захвата наша. Проверяется это тестами задачи `pocketbase-storage`, а не + чужим набором проверок. +- `−` захват идёт сырым запросом мимо записей PocketBase: хуки коллекции на нём + не срабатывают, и поле времени изменения проставляет наш код. +- `−` приборной панели очереди — числа ждущих, упавших, среднего времени — не + появляется. Смотрим таблицу коллекции в панели PocketBase, отбирая фильтром. diff --git a/docs/adr/README.md b/docs/adr/README.md index 339033e..71cf8f4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | | | 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | | | 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index a3ee149..c283c64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,10 @@ делят одну базу. Отдельного воркер-процесса нет намеренно. - **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в - день (оценка владельца, не замер: `research/` пуст). + день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — + решено 2026-08-11, + [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение + кандидатов в [research/job-queue.md](research/job-queue.md). - **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине, достаётся снова по истечении срока захвата и проходит шаг заново. @@ -123,9 +126,8 @@ раскладку на диске — решено 2026-08-11, [ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), замер панели в [research/pocketbase.md](research/pocketbase.md). Требование CGO этим - снимается. Не решено, чем становится конвейер задач: таблицей PocketBase с тем - же захватом или чем-то другим — разведка `job-queue-choice`. Данные не - переносим — начинаем с чистого листа. + снимается. Чем становится конвейер задач, решено 2026-08-11 — см. «Очередь» + ниже. Данные не переносим — начинаем с чистого листа. - **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера обрабатывает PocketBase, а не наш код (тот же ADR). Не решено, где живёт сессия и как связываются пользователь Telegram и пользователь веба. Панель @@ -166,11 +168,14 @@ сервис определяет содержимое сам, то ли часть записей теряется на этом. - **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но конвертер этот случай не проверялся. -- **Очередь.** Принцип «очередь таблицей» и захват `FindAndAcquire` написаны - вручную; чем именно захват несовершенен — [database.md](database.md), - «Представление данных». Не решено здесь другое: пересматривать ли модель - очереди целиком — разведка `job-queue-choice`, раньше смены хранилища, чтобы - не переписывать захват дважды. +- **Очередь.** Модель очереди решена 2026-08-11: остаётся своей таблицей и + становится коллекцией PocketBase, захват сворачивается в один запрос с + `RETURNING`, число попыток ложится колонкой, а исчерпавшая их задача переходит + в состояние «мертва» вместо `is_error = 1` + ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)). Пишет это + `pocketbase-storage` тем же заходом, что и хранилище. Не решено, отказываться + ли от холостого опроса: три воркера дают 259 200 запросов в сутки при нагрузке + в единицы записей в день, и во что это обходится, никто не мерил. - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в diff --git a/docs/research/README.md b/docs/research/README.md index 742bd1e..7658cff 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -9,7 +9,7 @@ ## Как снималось На живом потоке не снималось ничего: поведение внешних сервисов на границах не -проверяли. Единственная запись сделана на пустой базе в песочнице. +проверяли. Обе записи сделаны в песочнице на пустой базе. Внешних источников, о которых разведка нужна, четыре — Telegram Bot API, Yandex SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, что стоит @@ -21,4 +21,5 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт | Дата | Запись | О чём | | --- | --- | --- | +| 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase | | 2026-08-11 | [PocketBase: что даёт панель администратора](pocketbase.md) | Записи, пользователи и файлы в панели версии 0.39.10 | diff --git a/docs/research/job-queue.md b/docs/research/job-queue.md new file mode 100644 index 0000000..9179212 --- /dev/null +++ b/docs/research/job-queue.md @@ -0,0 +1,140 @@ +# Очередь задач: своя таблица против готовой библиотеки + +Отвечает на вопрос разведки `job-queue-choice`: брать ли готовую очередь на Go +поверх той же встроенной базы или оставить свою таблицу, дописав к ней повторы, +счётчик попыток и очередь мёртвых задач. Разведка шла перед `pocketbase-storage`, +потому что смена хранилища переписывает захват задачи в любом случае. + +Внешнего брокера — Redis, RabbitMQ, NATS — не рассматривали по рамке задачи: он +добавляет к выкладке процесс, которого там нет, ради нагрузки в единицы записей +в день. + +## Как снималось + +Дата замеров — 2026-08-11. Всё считал в каталоге вне репозитория, который +удалён вместе с песочницей; боевые данные не участвовали. + +- **Цена зависимости.** Завёл пустой модуль на Go 1.24 с одним PocketBase + 0.39.10, затем его копии с добавленной библиотекой. Пакеты в сборке — + `CGO_ENABLED=0 go list -deps .`, модули в графе — `go list -m all`. +- **Захват одним запросом.** Программа на 40 строк в той же песочнице: таблица + из одной строки, три горутины разом выполняют один и тот же запрос + `UPDATE … WHERE id = (SELECT … LIMIT 1) RETURNING …`. Драйвер — + `modernc.org/sqlite` v1.55.0, тот самый, которым ходит в базу PocketBase, + режим журнала WAL, таймаут занятости 5 секунд. +- **Свойства библиотек** взяты из их документации, а не замерены: пометки + «объявлено» ниже стоят именно там. +- **Холостой опрос** не мерил, а посчитал: три воркера и пауза 1 секунда из + [../database.md](../database.md), «Настройки с числовым значением», дают + 3 × 86 400 = **259 200 запросов к базе в сутки** независимо от того, есть ли + работа. + +## Готовой очереди для PocketBase на Go нет + +Проверил по списку экосистемы `awesome-pocketbase` и по обсуждениям в +репозитории PocketBase. Единственная очередь в списке — `pocketbase-queue`, +написана на TypeScript и работает из JS-хуков; из Go её не подключить. Она +заводит три коллекции (`queue_tasks`, `queue_locks`, `queue_stats`), упавшие +задачи держит с текстом ошибки семь дней и объявляет 50–60 задач в секунду на +четырёх воркерах. Ни нарастающей паузы, ни счётчика попыток у неё нет. + +Автор PocketBase в обсуждении № 2101 советует ровно свою коллекцию с полями +«имя, данные, состояние» и обход её по расписанию, а про встроенную очередь +говорит: «очередь писем, а может и общая очередь задач, есть в моих планах, но +пока приоритет низкий». Планировщик у PocketBase свой, `app.Cron()`. + +Отсюда разрез сравнения: выбор идёт не между готовым и своим, а между **своим в +коллекции PocketBase** и **чужой очередью, живущей рядом с PocketBase и мимо её +панели**. River и goqite про PocketBase не знают. + +## Захват чинится одним запросом + +Сегодняшний захват — два запроса подряд без транзакции +([../database.md](../database.md), «Представление данных»). Замер показал, что +после перехода на PocketBase он сворачивается в один: движок за +`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх +горутинах разом запись получила **ровно одна**. + +Это снимает главный довод в пользу чужой библиотеки: транзакционность захвата +покупается одной строкой запроса, а не новой зависимостью. + +## Кандидаты + +| | Своя таблица коллекцией | River 0.43.0 | goqite 0.4.0 | +| --- | --- | --- | --- | +| Пакетов в сборке сверх PocketBase | 0 | 46 | 3 | +| Модулей в графе сверх PocketBase | 0 | 18 | 7 | +| Требует CGO | нет | нет | нет | +| Видна в панели PocketBase | да, правится | нет | нет | +| Повторы с нарастающей паузой | писать | есть | нет | +| Счётчик попыток | писать | есть | есть, предел выдач | +| Очередь мёртвых задач | писать | есть, состояние «отброшена» | нет | +| Ожидание без траты попытки | писать | есть | нет | + +Числа зависимостей: с одним PocketBase в сборке 122 внешних пакета и 72 модуля +в графе; с River и его драйвером SQLite — 168 и 90; с goqite и его пакетом +задач — 125 и 79. Ни в одной сборке `mattn/go-sqlite3` не участвует: у goqite он +значится в графе, но только как зависимость его собственных проверок, и при +`CGO_ENABLED=0` всё три варианта собираются. + +### River + +Драйвер SQLite (`riverdriver/riversqlite`) появился в версии 0.23.0 и авторами +объявлен опытным: «схема ещё может быть слегка изменена, прежде чем её сочтут +окончательной». Объявленная скорость — четверть от той, что даёт Postgres, около +10 000 задач в секунду; для единиц записей в день это запас, которым мы не +воспользуемся. Свою веб-панель River даёт встраиваемым обработчиком, отдельного +процесса она не требует. + +Ложится на нашу задачу River лучше всех по одному месту: ожидание операции +SpeechKit длиной до суток выражается его отложением, и попытка при этом не +тратится. Всё остальное против: + +- очередь становится **цепочкой задач вместо состояния в таблице**, а это + переписывание `internal/service`, а не хранилища; +- свои таблицы River заводит сам, и панель PocketBase их не покажет: она знает + только свои коллекции. Показать их можно коллекцией-представлением, и та + **только для чтения** — повторить мёртвую задачу из панели не выйдет; +- панелей становится две, и у второй свой вход, который тоже надо закрывать на + обратном прокси; +- документация советует пул в одно соединение, чтобы не ловить отказ по + занятости, — поверх файла, который уже держит PocketBase. + +### goqite + +Самая дешёвая по зависимостям и самая бедная по существу. Сообщение — двоичное +тело в одной колонке: в панели оно нечитаемо. По умолчанию срок невидимости 5 +секунд и предел выдач 3; нарастающей паузы нет, очереди мёртвых задач нет — +исчерпавшее предел сообщение просто перестаёт выдаваться. Это молчаливая потеря +принятой записи, а она запрещена инвариантом «Принятая запись не теряется молча» +([../../CLAUDE.md](../../CLAUDE.md), «Инварианты»). То есть счётчик попыток и +очередь мёртвых пришлось бы дописывать и поверх goqite — ровно то, ради чего +разведка затевалась. + +## Что решено и от чего отказались + +Решение — **своя таблица, но коллекцией PocketBase**: захват одним запросом с +`RETURNING`, счётчик попыток колонкой, нарастающая пауза через существующий +`delay_time`, состояние «мертва» вместо `is_error = 1`. Записано в +[ADR-2026-08-11-queue-as-pocketbase-collection](../adr/ADR-2026-08-11-queue-as-pocketbase-collection.md). + +Отвергнуты: + +- **River с драйвером SQLite** — покупает повторы, счётчик и мёртвых готовыми, но + выносит очередь из панели PocketBase, ради которой хранилище и переезжает, и + переписывает конвейер в цепочку задач. Опытный драйвер со сменной схемой + добавляет к этому обязанность следить за чужими миграциями; +- **goqite** — не отвечает ни на один из трёх вопросов задачи целиком, а его + предел выдач молча теряет запись; +- **`pocketbase-queue`** — на TypeScript, из Go не подключается. + +## Чего разведка не узнала + +- **Сколько стоит написать недостающее.** Объём работы по повторам, счётчику + попыток и мёртвым задачам не оценивался: он входит в + `pocketbase-storage`, которая переписывает репозиторий целиком. +- **Ложится ли суточное ожидание операции SpeechKit на River без сюрпризов.** + Проверка стоит написания кода, а выбранному способу она не нужна вовсе. +- **Нужен ли отказ от холостого опроса.** 259 200 запросов в сутки посчитаны, а + во что они обходятся на файле базы — нет. Процесс один, и разбудить воркер + внутри него можно каналом, но задачи на это нет. diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index d853900..b71a95f 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -24,7 +24,7 @@ - [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками. - [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата. - [🔬 Очередь задач: своя таблица или готовая библиотека](items/job-queue-choice.md) — Очередь написана вручную: захват двумя запросами без транзакции, протухание временем, опрос раз в секунду вхолостую тремя воркерами. -- [🧹 Перевести хранилище и файлы записей на встроенный PocketBase](items/pocketbase-storage.md) — Записи, метаданные и файлы лежат порознь, и владелец не видит их ничем, кроме консоли на сервере: панель PocketBase покажет и то и другое, только если они переедут к ней. +- [🧹 Перевести хранилище и файлы записей на встроенный PocketBase](items/pocketbase-storage.md) — Записи, метаданные и файлы лежат порознь, и владелец не видит их ничем, кроме консоли на сервере: панель PocketBase покажет их, только если они переедут к ней. Заодно переписывается захват задачи: сегодня это два запроса без транзакции, а падающая всегда задача падает вечно. - [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. - [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать. - [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. diff --git a/tasks/items/pocketbase-storage.md b/tasks/items/pocketbase-storage.md index 186ba56..3a3a990 100644 --- a/tasks/items/pocketbase-storage.md +++ b/tasks/items/pocketbase-storage.md @@ -2,7 +2,7 @@ - **Тип:** chore - **Категория:** Очередь -- **Зачем:** Записи, метаданные и файлы лежат порознь, и владелец не видит их ничем, кроме консоли на сервере: панель PocketBase покажет и то и другое, только если они переедут к ней. +- **Зачем:** Записи, метаданные и файлы лежат порознь, и владелец не видит их ничем, кроме консоли на сервере: панель PocketBase покажет их, только если они переедут к ней. Заодно переписывается захват задачи: сегодня это два запроса без транзакции, а падающая всегда задача падает вечно. PocketBase встраивается библиотекой в тот же бинарник и приносит хранилище, учётные записи и панель администратора разом. Конвейер и оба входа работают @@ -20,12 +20,22 @@ PocketBase встраивается библиотекой в тот же бин Данные не переносим — база заводится с чистого листа, и это решение принято сознательно. +**Очередь переписывается тем же заходом.** Разведка `job-queue-choice` 2026-08-11 +[отвергла готовые библиотеки](../../docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md): +очередь остаётся своей таблицей, но становится коллекцией PocketBase. Захват +сворачивается в один запрос с `RETURNING`, число попыток ложится колонкой, +нарастающая пауза выражается существующим `delay_time`, а исчерпавшая попытки +задача переходит в состояние «мертва» вместо `is_error = 1`. Сравнение +кандидатов — [docs/research/job-queue.md](../../docs/research/job-queue.md). + ## Затрагивает - таблицы `files` и `transcribe_jobs`, каталог `migrations/` и весь механизм goose; - `internal/adapter/repo/sqlite` целиком, включая захват задачи через `FindAndAcquire`; +- состав колонок очереди: прибавляется число попыток, а `is_error` уступает + место состоянию «мертва» в перечне состояний задачи; - `internal/contract`, интерфейсы `FileRepository` и `TranscriptJobRepository`; - ключ конфигурации `[database] path`, ключ `[storage] path` и раскладка каталога `data/`; @@ -47,14 +57,19 @@ PocketBase встраивается библиотекой в тот же бин - Сервис поднимается на чистом каталоге данных, накатывает свою схему сам и принимает запись обоими входами. Оракул — запуск на пустом `data/` и прогон записи из Telegram и через `POST /api/audio` до состояния `done`. -- Захват задачи воркером не выдаёт одну запись двум вызывающим. Оракул — тест - на трёх параллельных вызовах захвата по одному состоянию: ровно один - получает запись. -- Задача, брошенная на середине, достаётся снова по истечении срока захвата. - Оракул — тест с проставленным задним числом `acquire_time`. -- Принятая запись видна в панели строкой и скачивается из неё файлом. Оракул — - прогон записи через `POST /api/audio` на пустом каталоге, затем запрос - `/api/files/...` за тем же файлом: длина совпадает с загруженной. +- Захват задачи воркером идёт одним запросом и не выдаёт одну запись двум + вызывающим. Оракул — тест на трёх параллельных вызовах захвата по одному + состоянию: ровно один получает запись. +- Задача, брошенная на середине, достаётся снова по истечении срока захвата, а + падающая всегда — уходит в «мертва» и из выборки исчезает. Оракулы — тест с + проставленным задним числом `acquire_time` и тест с шагом, падающим на каждой + попытке: после заданного их числа задача не выдаётся, а её состояние видно + отбором. +- Принятая запись видна в панели строкой и скачивается по ссылке + `/api/files/...` тем же файлом. Оракулы — прогон записи через + `POST /api/audio` на пустом каталоге, затем поиск её строки в коллекции задач + на `/_/` по идентификатору и запрос `/api/files/...` за тем же файлом: длина + совпадает с загруженной. - `docs/database.md` описывает новую схему, а старые упоминания goose и goqu из документов канона убраны. Оракул — `task gate`, шаг `docs.py check`.