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). Каждый из этих процессов будет жить в своём фичевом модуле, но всем им нужен общий механизм запуска по расписанию. Требования:
- Единый способ объявить расписание — конфигурируемый интервал или cron, имя для логов и метрик, целевая BullMQ-очередь и имя джобы.
- Защита от наложений (anti-overlap). Если тик случился, а предыдущая джоба ещё не отработала — не плодить дубликаты. Это особенно важно для коротких расписаний (минута, секунды) и для clustered-деплоя (несколько инстансов backend могут одновременно «выстрелить» один и тот же тик).
- Per-кабинет анти-наезд (требование тикета). Если расписание фан-аутится на N аккаунтов, два тика подряд не должны обрабатывать один и тот же аккаунт одновременно.
- Никаких новых внешних зависимостей — Redis и BullMQ уже в стеке (ADR-0007), всё, что нужно — это сторона «отсчёта времени».
- Прозрачность. Расписания должны быть видны в логах и (в перспективе)
в
/metrics— единое место, а не разбросанные@Cron-декораторы.
Рассмотренные варианты
Источник «тиков» (cron/interval)
@nestjs/schedule.- Идиоматичная для NestJS, в комплекте
SchedulerRegistryдля динамической регистрации черезaddCronJob/addInterval. - Поддерживает cron (через библиотеку
cron) и interval. - Никакого нового рантайма — пакет добавляет лишь dev-API. − Срабатывает per-process; в clustered-деплое каждый инстанс выстрелит свой тик. Решается на уровне дедупа джоб (см. ниже).
- Идиоматичная для NestJS, в комплекте
bull-mq Repeatable Jobs.- Один владелец расписания в кластере — естественно даёт
anti-overlap из-за уникального
repeatJobKey. − API менее очевидное (нужноremoveRepeatableдля апдейта), отладка через UI обязательна (не видно «следующего тика» в логах). − Нет понятия «расписание зарегистрировано в этом процессе» — планы живут в Redis и переживают рестарт. Это плюс для прод-стабильности, но минус для read-only/dry-run сценариев dev.
- Один владелец расписания в кластере — естественно даёт
anti-overlap из-за уникального
- Внешний шедулер (
node-cron,cronicle, k8s CronJob).- Полностью независим от приложения. − Привносит ещё одно место, где надо хранить и деплоить конфигурацию расписаний. Cronicle/k8s — overkill для текущего масштаба.
Anti-overlap
- Детерминированный
jobIdBullMQ. Каждый тик вычисляет${name}:${slot}, гдеslot— целочисленный номер «окна»:floor(now/intervalMs)для interval илиfloor(now/1000)для cron. BullMQ молча отбрасываетadd()с уже существующимjobId(если предыдущая джоба ещё вwaiting/active/delayed). Получаем:- Внутри процесса: второй setInterval-тик в том же окне молча сольётся.
- Между процессами: два инстанса в одном слоте сгенерируют
одинаковый
jobId→ один проходит, остальные отбрасываются.
- Redis distributed lock (Redlock /
SET NX).- Универсальный механизм, можно навесить «семантические» ключи
(например, по
accountId). − Лишний раунд-трип в Redis на каждый тик. Сложность очистки осевших locks при падении процесса. − Дублирует то, что BullMQ уже делает черезjobId-дедуп.
- Универсальный механизм, можно навесить «семантические» ключи
(например, по
- Repeatable Jobs из BullMQ (см. выше) — даёт anti-overlap «бесплатно», но за счёт перехода на другой API.
Per-account fan-out
- Делать в воркере. Расписание ставит одну «зонтичную» джобу,
воркер фичевого модуля сам перечисляет аккаунты и ставит per-account
джобы (с детерминированным
jobId = ${name}:${accountId}:${slot}— тот же приём анти-наезда). - Делать в реестре. Расписание знает про аккаунты — реестр итерирует и ставит 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.
- Развязали ответственности: «когда тикнуть» (реестр) и «что сделать с тиком» (воркер фичевого модуля).
- Фичевые модули получают одинаковый API:
- Отрицательные / компромиссы:
- Тик каждого процесса всегда дойдёт до 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 (мониторинг очередей).
- Тик каждого процесса всегда дойдёт до BullMQ-
- Что нужно сделать дальше:
- OP-43 (Мониторинг очередей и метрики джоб) — естественное место,
куда подключить
/metricsдля расписаний: длина очереди, lag, дедупликации (add()→ существующийjobId). - OP-58 и аналоги (Ежедневная синхронизация оцифровки) — первый реальный потребитель реестра. Покажет паттерн per-account fan-out в воркере (см. «Решение»).
- Расписания в боевом коде объявлять в
onModuleInit()фичевого модуля, документировать в его*.module.tsTSDoc.
- OP-43 (Мониторинг очередей и метрики джоб) — естественное место,
куда подключить