Перейти к основному содержимому

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-контента) выполняют фоновую обработку с периодичностью от минут до часов. Нужна общая инфраструктура:

  1. Доставка джоб с гарантией at-least-once.
  2. Повторы и backoff для transient-ошибок (сетевой сбой, 429/5xx от WB API, временная деградация Postgres).
  3. Изоляция между окружениями — dev/stage/prod часто делят один Redis-инстанс на VPS заказчика; путаница очередей недопустима.
  4. Dead-letter — упавшие после ретраев джобы должны сохраняться для расследования, а не теряться.
  5. Graceful shutdown воркеров: текущая джоба либо доводится до конца, либо корректно возвращается в очередь.

Рассмотренные варианты​

Движок очередей​

  1. BullMQ поверх Redis (bullmq + @nestjs/bullmq).
    • Активно поддерживается, нативная интеграция с NestJS, встроенные retries/backoff/delayed/repeatable, UI (bull-board).
    • Redis уже есть в стеке (F1) — не вводим новую зависимость. − Все джобы держатся в RAM Redis; для очень больших очередей нужно следить за maxmemory и эвикцией.
  2. bee-queue на Redis. Проще API, но не развивается, нет stream-based воркеров и нативного NestJS-интегратора.
  3. agenda на MongoDB. Привнёс бы Mongo в стек ради одной задачи — стоимость зависимости перевешивает выигрыш.
  4. Внешний брокер (RabbitMQ / Kafka) — overkill для текущих объёмов (десятки джоб/мин на кабинет); добавляет операционное обслуживание и резервирование. Если в будущем понадобится широковещание/реплеи — это будет отдельная миграция.

Redis-соединение​

  1. Выделенное соединение per BullMQ. Библиотека сама поднимает blocking-конексии для каждой Queue/Worker (BRPOP, XREADGROUP).
  2. Шарить общий REDIS_CLIENT (из core/redis). Меньше коннектов, но блокирующие команды воркеров заморозят весь Redis-трафик: кэш (OP-39) и rate-лимитер WB (OP-34) встанут на время block-таймаута.

Изоляция окружений​

  1. Префикс ключей через QUEUE_PREFIX. Все ключи BullMQ живут под общим префиксом — optima:{queueName}:* (для prod) / optima-stage:{queueName}:* (для stage). Простое, явное, ни одного воркера не «перепутает» окружения.
  2. Отдельные базы Redis (SELECT db-id). Поддержано ioredis, но усложняет операционную жизнь: разные команды для просмотра, нельзя удобно агрегировать метрики, легко промахнуться с номером базы.
  3. Отдельные Redis-инстансы. Идеальная изоляция, но это уже решение уровня инфраструктуры (Coolify), а не приложения.

Dead-letter​

  1. removeOnFail: false — джобы, упавшие после всех ретраев, остаются в наборе failed BullMQ. Чтение, диагностика и retry — через стандартные методы Queue.
  2. Кастомная очередь *-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% случаев; модули переопределяют точечно.
  • Отрицательные / компромиссы:
    • BullMQ держит джобы в RAM Redis — при большом отставании воркеров нужно мониторить maxmemory. Метрики придут в OP-42.
    • Чистка failed-набора не автоматическая. Принято: лучше разрастание failed (с алертом), чем потеря диагностики.
    • lazyConnect: true означает: ошибка коннекта к Redis всплывёт только при первой попытке отправить/принять джобу. Это согласовано с поведением core/redis и проверяется отдельно через health-checks.
  • Дальше (вне scope OP-41):
    • OP-42 — метрики очередей (длина, время в очереди, ошибки) для Prometheus/Grafana.
    • Конкретные джобы модулей появляются в фичевых тикетах эпиков OP-9..OP-23.
    • При желании — UI bull-board под basic-auth по аналогии со Swagger (SWAGGER_USER/SWAGGER_PASSWORD).