0007. Инфраструктура очередей: BullMQ + выделенное Redis-соединение
Копия ADR из репозитория:
backend/docs/adr/0007-bullmq-queue-infrastructure.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-06-04
- Связанные задачи: OP-41 (F1 · Инфраструктура очередей)
Контекст и проблема
Большинство фичевых модулей «Оптимы» (5.1–5.11 из ТЗ — синхронизация заказов/остатков/цен, автобиддер, репрайсер, аналитика РНП, генерация LLM-контента) выполняют фоновую обработку с периодичностью от минут до часов. Нужна общая инфраструктура:
- Доставка джоб с гарантией at-least-once.
- Повторы и backoff для transient-ошибок (сетевой сбой, 429/5xx от WB API, временная деградация Postgres).
- Изоляция между окружениями — dev/stage/prod часто делят один Redis-инстанс на VPS заказчика; путаница очередей недопустима.
- Dead-letter — упавшие после ретраев джобы должны сохраняться для расследования, а не теряться.
- Graceful shutdown воркеров: текущая джоба либо доводится до конца, либо корректно возвращается в очередь.
Рассмотренные варианты
Движок очередей
- BullMQ поверх Redis (
bullmq+@nestjs/bullmq).- Активно поддерживается, нативная интеграция с NestJS, встроенные
retries/backoff/delayed/repeatable, UI (
bull-board). - Redis уже есть в стеке (F1) — не вводим новую зависимость.
− Все джобы держатся в RAM Redis; для очень больших очередей
нужно следить за
maxmemoryи эвикцией.
- Активно поддерживается, нативная интеграция с NestJS, встроенные
retries/backoff/delayed/repeatable, UI (
bee-queueна Redis. Проще API, но не развивается, нет stream-based воркеров и нативного NestJS-интегратора.agendaна MongoDB. Привнёс бы Mongo в стек ради одной задачи — стоимость зависимости перевешивает выигрыш.- Внешний брокер (RabbitMQ / Kafka) — overkill для текущих объёмов (десятки джоб/мин на кабинет); добавляет операционное обслуживание и резервирование. Если в будущем понадобится широковещание/реплеи — это будет отдельная миграция.
Redis-соединение
- Выделенное соединение per BullMQ. Библиотека сама поднимает
blocking-конексии для каждой Queue/Worker (
BRPOP,XREADGROUP). - Шарить общий
REDIS_CLIENT(изcore/redis). Меньше коннектов, но блокирующие команды воркеров заморозят весь Redis-трафик: кэш (OP-39) и rate-лимитер WB (OP-34) встанут на время block-таймаута.
Изоляция окружений
- Префикс ключей через
QUEUE_PREFIX. Все ключи BullMQ живут под общим префиксом —optima:{queueName}:*(для prod) /optima-stage:{queueName}:*(для stage). Простое, явное, ни одного воркера не «перепутает» окружения. - Отдельные базы Redis (
SELECT db-id). Поддержано ioredis, но усложняет операционную жизнь: разные команды для просмотра, нельзя удобно агрегировать метрики, легко промахнуться с номером базы. - Отдельные Redis-инстансы. Идеальная изоляция, но это уже решение уровня инфраструктуры (Coolify), а не приложения.
Dead-letter
removeOnFail: false— джобы, упавшие после всех ретраев, остаются в набореfailedBullMQ. Чтение, диагностика и retry — через стандартные методы Queue.- Кастомная очередь
*-dlq. Воркер перехватываетfailed-event и перекладывает payload в отдельную очередь. Гибче, но без явной потребности — преждевременная сложность.
Решение
Движок: BullMQ + @nestjs/bullmq
Поверх существующего Redis. Воркеры — отдельные Nest-провайдеры
(@Processor), регистрируются в фичевых модулях через
BullModule.registerQueue({ name }).
Соединение: выделенное
QueueModule отдаёт BullMQ конфиг с { url: REDIS_URL, maxRetriesPerRequest: null, lazyConnect: true }. BullMQ сам создаёт
нужные ioredis-клиенты per-queue/per-worker. Общий REDIS_CLIENT
из core/redis остаётся монопольно за кэшем и rate-лимитером;
блокирующие команды воркеров не влияют на остальной трафик.
maxRetriesPerRequest: null — жёсткое требование BullMQ для
blocking-конексий: без него ioredis выбрасывает команды по таймауту,
ломая воркеры в момент кратковременного сетевого мерцания Redis.
Изоляция: префикс QUEUE_PREFIX
Env-переменная QUEUE_PREFIX (по умолчанию optima) задаёт корневой
префикс для всех ключей BullMQ. В Coolify задаётся per-окружение:
prod → optima, stage → optima-stage. Тесты (jest/e2e) задают
уникальный префикс на прогон, чтобы исключить пересечения.
Defaults обработки джоб
Заданы на уровне QueueModule.forRoot (см.
src/core/queue/queue.constants.ts: DEFAULT_JOB_OPTIONS):
attempts: 5— 1 основная + 4 ретрая. Закрывает короткие сетевые сбои и одиночные деградации зависимостей, не амплифицируя нагрузку при длительном отказе.backoff: exponential 1s— задержки 1s, 2s, 4s, 8s между попытками (~15 секунд суммарно). Достаточно для transient-восстановления, не приводит к DDoS-эффекту.removeOnComplete: { age: 24h, count: 1000 }— успешные джобы держим сутки для отладки, но не более 1000 шт. на очередь.removeOnFail: false— упавшие после всех ретраев сохраняются в набореfailedнавсегда (dead-letter), чистка ведётся вручную или отдельным процессом.
Фичевый модуль может переопределить любой из параметров при
queue.add(name, data, { attempts: 1, ... }) — например, отключить
ретраи для идемпотентно-критичной задачи.
Graceful shutdown
Получается «из коробки»: BullModule регистрирует Queue/Worker
как Nest-провайдеры, app.enableShutdownHooks() (уже включён в
app.bootstrap.ts) корректно дожидается завершения текущих джоб
и закрывает ioredis-конексии.
Последствия
- Положительные:
- Все фичевые модули получают единый API:
@InjectQueue('foo')+@Processor('foo'), без повторной настройки подключения. - Воркеры не блокируют общий Redis-трафик (кэш, rate-лимитер).
- dev/stage/prod чисто изолированы префиксом — деплой stage никогда не «съест» джобы prod.
- Дефолты ретраев/backoff удовлетворяют 80% случаев; модули переопределяют точечно.
- Все фичевые модули получают единый API:
- Отрицательные / компромиссы:
- BullMQ держит джобы в RAM Redis — при большом отставании воркеров
нужно мониторить
maxmemory. Метрики придут в OP-42. - Чистка
failed-набора не автоматическая. Принято: лучше разрастаниеfailed(с алертом), чем потеря диагностики. lazyConnect: trueозначает: ошибка коннекта к Redis всплывёт только при первой попытке отправить/принять джобу. Это согласовано с поведениемcore/redisи проверяется отдельно через health-checks.
- BullMQ держит джобы в RAM Redis — при большом отставании воркеров
нужно мониторить
- Дальше (вне scope OP-41):
- OP-42 — метрики очередей (длина, время в очереди, ошибки) для Prometheus/Grafana.
- Конкретные джобы модулей появляются в фичевых тикетах эпиков OP-9..OP-23.
- При желании — UI
bull-boardпод basic-auth по аналогии со Swagger (SWAGGER_USER/SWAGGER_PASSWORD).