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

0009. Реестр периодических задач: @nestjs/schedule + anti-overlap через детерминированный jobId

Копия ADR из репозитория: backend/docs/adr/0009-periodic-task-scheduler.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-06-05
  • Связанные задачи: OP-42 (F1 · Планировщик периодических задач)

Контекст и проблема​

ТЗ задаёт разную периодичность фоновых процессов: продажи — ежеминутно, реклама — 5–10 мин, финансы/оцифровка — 1/день, статистика — 1/час, ставки — 1–2 мин, слоты — 1–5 сек (см. §5.1, 5.4, 5.6, 5.8, 5.11). Каждый из этих процессов будет жить в своём фичевом модуле, но всем им нужен общий механизм запуска по расписанию. Требования:

  1. Единый способ объявить расписание — конфигурируемый интервал или cron, имя для логов и метрик, целевая BullMQ-очередь и имя джобы.
  2. Защита от наложений (anti-overlap). Если тик случился, а предыдущая джоба ещё не отработала — не плодить дубликаты. Это особенно важно для коротких расписаний (минута, секунды) и для clustered-деплоя (несколько инстансов backend могут одновременно «выстрелить» один и тот же тик).
  3. Per-кабинет анти-наезд (требование тикета). Если расписание фан-аутится на N аккаунтов, два тика подряд не должны обрабатывать один и тот же аккаунт одновременно.
  4. Никаких новых внешних зависимостей — Redis и BullMQ уже в стеке (ADR-0007), всё, что нужно — это сторона «отсчёта времени».
  5. Прозрачность. Расписания должны быть видны в логах и (в перспективе) в /metrics — единое место, а не разбросанные @Cron-декораторы.

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

Источник «тиков» (cron/interval)​

  1. @nestjs/schedule.
    • Идиоматичная для NestJS, в комплекте SchedulerRegistry для динамической регистрации через addCronJob/addInterval.
    • Поддерживает cron (через библиотеку cron) и interval.
    • Никакого нового рантайма — пакет добавляет лишь dev-API. − Срабатывает per-process; в clustered-деплое каждый инстанс выстрелит свой тик. Решается на уровне дедупа джоб (см. ниже).
  2. bull-mq Repeatable Jobs.
    • Один владелец расписания в кластере — естественно даёт anti-overlap из-за уникального repeatJobKey. − API менее очевидное (нужно removeRepeatable для апдейта), отладка через UI обязательна (не видно «следующего тика» в логах). − Нет понятия «расписание зарегистрировано в этом процессе» — планы живут в Redis и переживают рестарт. Это плюс для прод-стабильности, но минус для read-only/dry-run сценариев dev.
  3. Внешний шедулер (node-cron, cronicle, k8s CronJob).
    • Полностью независим от приложения. − Привносит ещё одно место, где надо хранить и деплоить конфигурацию расписаний. Cronicle/k8s — overkill для текущего масштаба.

Anti-overlap​

  1. Детерминированный jobId BullMQ. Каждый тик вычисляет ${name}:${slot}, где slot — целочисленный номер «окна»: floor(now/intervalMs) для interval или floor(now/1000) для cron. BullMQ молча отбрасывает add() с уже существующим jobId (если предыдущая джоба ещё в waiting/active/delayed). Получаем:
    • Внутри процесса: второй setInterval-тик в том же окне молча сольётся.
    • Между процессами: два инстанса в одном слоте сгенерируют одинаковый jobId → один проходит, остальные отбрасываются.
  2. Redis distributed lock (Redlock / SET NX).
    • Универсальный механизм, можно навесить «семантические» ключи (например, по accountId). − Лишний раунд-трип в Redis на каждый тик. Сложность очистки осевших locks при падении процесса. − Дублирует то, что BullMQ уже делает через jobId-дедуп.
  3. Repeatable Jobs из BullMQ (см. выше) — даёт anti-overlap «бесплатно», но за счёт перехода на другой API.

Per-account fan-out​

  1. Делать в воркере. Расписание ставит одну «зонтичную» джобу, воркер фичевого модуля сам перечисляет аккаунты и ставит per-account джобы (с детерминированным jobId = ${name}:${accountId}:${slot} — тот же приём анти-наезда).
  2. Делать в реестре. Расписание знает про аккаунты — реестр итерирует и ставит N джоб.

Решение​

Источник тиков: @nestjs/schedule​

ScheduleModule.forRoot() из @nestjs/schedule подключается в core/schedule/ScheduleModule (Global). Динамическая регистрация — через SchedulerRegistry.addInterval(name, setInterval(...)) для interval-расписаний и SchedulerRegistry.addCronJob(name, CronJob.from(...)) для cron.

Декораторы @Cron/@Interval фичевыми модулями не используются — все расписания регистрируются явно через ScheduleRegistryService.register(def) в onModuleInit() фичевого модуля. Причины:

  • Видны все расписания в одном месте (list()), удобно для /metrics и для status-страницы Layer-2.
  • Можно подменить в тестах (стабом SchedulerRegistry), не таская весь ScheduleModule.
  • Конфигурируемые интервалы (env / AppConfigService) естественно ложатся на программное API — декораторы такого не умеют.

Anti-overlap: детерминированный jobId по «намеренному» времени​

jobId = ${name}:${scheduledAt}, где scheduledAt — намеренное время тика, одинаковое для всех инстансов кластера независимо от реального момента, в который сработал таймер:

  • interval → floor(now / intervalMs) * intervalMs (начало текущего окна). Все процессы, попавшие в одно окно, видят одинаковый scheduledAt.
  • cron → CronJob.lastDate().getTime(), т.е. точное cron-выражением обусловленное время. Два инстанса кластера могут реально вызвать onTick на 10–50 мс врозь (NTP-разлёт, scheduler jitter), но lastDate у обоих даст одну и ту же отметку — то самое запланированное cron время.

Это достаточно для двух обозначенных в ТЗ требований:

  • Расписание не «лавиной» забивает очередь при отставании воркера.
  • Кластерный деплой не дублирует тики (NTP-разлёт больше не имеет значения — «снеппимся» на cron-метку, а не на Date.now()).

Distributed lock сознательно не вводим: BullMQ уже даёт нужное свойство, ещё один источник истины удвоил бы операционную сложность.

Eager-резолв очередей​

register() сразу резолвит def.queue через ModuleRef.get(getQueueToken(...)) и кэширует ссылку. Это даёт две вещи:

  • Fail-fast при typo / забытом BullModule.registerQueue. Регистрация валится с понятной ошибкой Очередь "X" не зарегистрирована, а не превращается в бесшумный тик не смог поставить джобу в логах после старта.
  • Никаких ModuleRef-лукапов на горячем пути. Для расписаний 1–5 секунд (слоты, торги) каждый тик — это уже только Redis-вызов и арифметика.

unref для interval-таймеров​

setInterval(...).unref() — таймер не держит event loop. CLI-команды (docs:openapi, миграции, ts-node-скрипты), подгружающие AppModule для интроспекции, корректно завершаются и не висят из-за фоновых тиков зарегистрированных схедулов.

Per-account fan-out: в воркере, не в реестре​

Расписание знает только о «когда» и «куда поставить». Перечисление аккаунтов, сегментирование по тенанту и формирование per-account джоб — ответственность воркера фичевого модуля. Тот же приём детерминированного jobId с включением accountId в слот даёт per-account анти-наезд: ${name}:${accountId}:${slot}.

Это держит реестр маленьким (не тянет связь с БД учётных записей) и позволяет каждому модулю настраивать политику фан-аута под себя (батчинг, исключения, приоритезация).

Последствия​

  • Положительные:
    • Фичевые модули получают одинаковый API: scheduleRegistry.register(def) в onModuleInit() + воркер на BullModule.registerQueue({ name }). Минимум boilerplate, максимум видимости в логах.
    • Anti-overlap бесплатный, не требует второго источника истины и не делает лишних обращений к Redis.
    • Развязали ответственности: «когда тикнуть» (реестр) и «что сделать с тиком» (воркер фичевого модуля).
  • Отрицательные / компромиссы:
    • Тик каждого процесса всегда дойдёт до BullMQ-add() (даже если будет отброшен по jobId-дубликату). На очень коротких расписаниях (1–5 сек, слоты) при большом количестве инстансов это даст N×рост count'а на add() — пренебрежимо мало (несколько ms на запрос), но фиксируем как осознанный компромисс vs. distributed lock.
    • Нет встроенной отмены расписания (unregister / pause / update). Если фича отключается runtime, нужно явно SchedulerRegistry.deleteInterval(name) или deleteCronJob. В MVP этого нет — добавим, когда появится feature-toggle расписаний.
    • QueueStub в тестах моделирует jobId-дедуп вручную; настоящее поведение BullMQ (отбрасывание дубликата add() при существующем jobId) проверяется только косвенно через стаб. Интеграционный тест на реальном Redis оставляем для OP-43 (мониторинг очередей).
  • Что нужно сделать дальше:
    • OP-43 (Мониторинг очередей и метрики джоб) — естественное место, куда подключить /metrics для расписаний: длина очереди, lag, дедупликации (add() → существующий jobId).
    • OP-58 и аналоги (Ежедневная синхронизация оцифровки) — первый реальный потребитель реестра. Покажет паттерн per-account fan-out в воркере (см. «Решение»).
    • Расписания в боевом коде объявлять в onModuleInit() фичевого модуля, документировать в его *.module.ts TSDoc.