Files
transcriber/docs/research/job-queue.md
T
av b76f2d7c7e docs: раскладка переехала в .av-dev.toml, а расхождения документов сведены
- Перевод на канон 1 доделан: адреса служебного файла и имена скиллов
  переставлены в девяти местах прозы и кода, гейт зовёт три скрипта по новым
  путям, прежние docs/.docs.json и tasks/.tasks.json удалены.
- Сверка двумя агентами нашла четырнадцать расхождений, тринадцать сведены
  строками: число прогонов ревью и преамбула журнала дефектов, счёт capability,
  маршруты README, дубли инварианта захвата и кодов прогона, протухшие указатели
  записок разведки, маркер долга на переехавшем абзаце. Срок жизни сессии
  нормирует спека access, database.md на неё ссылается.
- Purpose спеки pipeline объявляет неописанным то, что в ней же и стоит; правка
  идёт изменением openspec, поэтому заведена задача pipeline-spec-purpose-drift.
2026-08-13 12:36:36 +03:00

12 KiB
Raw Blame History

Очередь задач: своя таблица против готовой библиотеки

Отвечает на вопрос разведки 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, «Настройки с числовым значением», дают 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, «Представление данных». Замер показал, что после перехода на 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, «Инварианты»). То есть счётчик попыток и очередь мёртвых пришлось бы дописывать и поверх goqite — ровно то, ради чего разведка затевалась.

Что решено и от чего отказались

Решение — своя таблица, но коллекцией PocketBase: захват одним запросом с RETURNING, счётчик попыток колонкой, нарастающая пауза через существующий delay_time, состояние «мертва» вместо is_error = 1. Записано в ADR-2026-08-11-queue-as-pocketbase-collection.

Отвергнуты:

  • River с драйвером SQLite — покупает повторы, счётчик и мёртвых готовыми, но выносит очередь из панели PocketBase, ради которой хранилище и переезжает, и переписывает конвейер в цепочку задач. Опытный драйвер со сменной схемой добавляет к этому обязанность следить за чужими миграциями;
  • goqite — не отвечает ни на один из трёх вопросов задачи целиком, а его предел выдач молча теряет запись;
  • pocketbase-queue — на TypeScript, из Go не подключается.

Чего разведка не узнала

  • Сколько стоит написать недостающее. Объём работы по повторам, счётчику попыток и мёртвым задачам не оценивался: он входит в pocketbase-storage, которая переписывает репозиторий целиком.
  • Ложится ли суточное ожидание операции SpeechKit на River без сюрпризов. Проверка стоит написания кода, а выбранному способу она не нужна вовсе.
  • Нужен ли отказ от холостого опроса. 259 200 запросов в сутки посчитаны, а во что они обходятся на файле базы — нет. Процесс один, и разбудить воркер внутри него можно каналом, но задачи на это нет.