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

142 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Очередь задач: своя таблица против готовой библиотеки
Отвечает на вопрос разведки `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 запросов в сутки посчитаны, а
во что они обходятся на файле базы — нет. Процесс один, и разбудить воркер
внутри него можно каналом, но задачи на это нет.