# Очередь задач: своя таблица против готовой библиотеки Отвечает на вопрос разведки `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 не знают. ## Захват чинится одним запросом Захват **на момент замера** — два запроса подряд без транзакции; после перехода на PocketBase он свернулся в один с `RETURNING` — [../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 запросов в сутки посчитаны, а во что они обходятся на файле базы — нет. Процесс один, и разбудить воркер внутри него можно каналом, но задачи на это нет.