Scheduling & Cron (@nestjs/schedule)
TL;DR —
@nestjs/scheduleenveloppecron,setInterval,setTimeoutdans des décorateurs (@Cron,@Interval,@Timeout) et expose unSchedulerRegistrypour les schedules dynamiques. Piège massif : en multi-instances (k8s replicas), chaque pod exécute le cron → le job tourne N fois. Solution : leader election (Redis lock), externalisation (queue + 1 scheduler), ou cron managé (k8s CronJob). Pour des jobs lourds ou retryables, toujours combiner avec BullMQ.
🧠 Mental model — ASCII diagram + analogy
Analogie : un réveil. Sur 1 téléphone (1 instance), il sonne 1 fois à 7h. Sur 5 téléphones (5 replicas), il sonne 5 fois → tu reçois 5 emails au lieu d'un. Le scheduling distribué = un seul réveil "élu" par jour, ou un service de réveil centralisé (k8s).
Single instance:
┌──────────────┐
│ Nest App │ @Cron('0 7 * * *') ──▶ runs once ✅
└──────────────┘
Multi-instance (NAIVE):
┌────┐ ┌────┐ ┌────┐
│ #1 │ │ #2 │ │ #3 │ all fire at 07:00 ❌ (3 emails)
└────┘ └────┘ └────┘
Multi-instance (with leader lock):
┌────┐ ┌────┐ ┌────┐
│ #1 │ │ #2 │ │ #3 │
└─┬──┘ └─┬──┘ └─┬──┘
│ │ │
└───▶ Redis SETNX 'cron:daily-digest:2026-05-23' ───▶ only one wins ✅
Best (production):
┌──────────────────┐ ┌──────────────────┐
│ k8s CronJob OR │ ──add──▶│ BullMQ queue │ ──▶ Workers (any replica)
│ 1 dedicated pod │ └──────────────────┘
└──────────────────┘🛠️ Code minimal — realistic working snippet
Setup
// app.module.ts
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [ScheduleModule.forRoot()],
})
export class AppModule {}Cron / Interval / Timeout
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression, Interval, Timeout, SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
constructor(private registry: SchedulerRegistry) {}
@Cron('0 7 * * *', { name: 'daily-digest', timeZone: 'Europe/Paris' })
async dailyDigest() {
this.logger.log('Sending daily digest');
// ⚠️ multi-instance: protéger par un lock (voir plus bas)
}
@Cron(CronExpression.EVERY_30_MINUTES)
cleanupTempFiles() { /* ... */ }
@Interval('healthcheck', 10_000)
ping() { /* every 10s, no cron string needed */ }
@Timeout('warmup', 5_000)
warmupCache() { /* runs once 5s after boot */ }
// Dynamic schedule — API objet `cron` v3/v4 (signature positionnelle DEPRECATED)
addCron(name: string, expr: string, cb: () => void) {
if (this.registry.doesExist('cron', name)) {
throw new ConflictException(`cron ${name} déjà enregistré`); // évite le double-add silencieux
}
const job = CronJob.from({ // `from()` = factory typée, plus de `as any`
cronTime: expr,
onTick: cb,
timeZone: 'Europe/Paris',
start: false, // on démarre explicitement après l'enregistrement
});
this.registry.addCronJob(name, job);
job.start();
}
removeCron(name: string) {
this.registry.deleteCronJob(name);
}
}Leader election (Redis lock simple)
import Redis from 'ioredis';
@Injectable()
export class CronLock {
constructor(private redis: Redis) {}
/** Tente d'acquérir un lock atomique avec TTL. Returns true si gagné. */
async acquire(key: string, ttlMs: number): Promise<boolean> {
const res = await this.redis.set(key, process.env.HOSTNAME ?? 'pod', 'PX', ttlMs, 'NX');
return res === 'OK';
}
}
@Injectable()
export class GuardedTasks {
constructor(private lock: CronLock, private queue: EmailsService) {}
@Cron('0 7 * * *', { name: 'daily-digest', timeZone: 'Europe/Paris' })
async dailyDigest() {
const dateKey = new Date().toISOString().slice(0, 10);
const ok = await this.lock.acquire(`cron:daily-digest:${dateKey}`, 23 * 3600 * 1000);
if (!ok) return; // un autre pod l'a déjà fait
await this.queue.scheduleDigest(); // pousse en queue, ne traite pas inline
}
}Pattern recommandé : cron léger → push en queue
@Cron('*/5 * * * *')
async tickRetryWebhooks() {
if (!(await this.lock.acquire('cron:retry-webhooks', 4 * 60_000))) return;
await this.webhookQueue.add('retry-batch', {}, { jobId: `retry:${Date.now()}` });
}🎯 Patterns courants
- Cron → Queue — le cron ne fait QUE planifier. Le vrai travail vit dans un Worker BullMQ (retries, DLQ, observability). Le cron devient idempotent et léger.
- Leader election Redis —
SET key value NX PX ttlatomique. Le premier pod gagne, les autres no-op. TTL court < intervalle de cron. - Dedicated scheduler pod — un seul replica
schedulerdans ton deployment, distinct du replicaapi. Plus simple que le lock. Risque : SPOF — accepte-le, ton scheduling repartira au prochain redéploiement. - k8s CronJob natif — délègue le scheduling au cluster. Un Job k8s lance un pod éphémère qui exécute une commande Nest CLI (
node dist/main cli send-digest). Robuste, mais moins flexible que les schedules dynamiques. - Dynamic schedules persistés — pour permettre aux users de créer leurs propres crons (reminders), stocke en DB, et au boot recharge
SchedulerRegistry.addCronJob. Ne te fie jamais à la mémoire. - Timezones explicites — toujours
timeZone: 'Europe/Paris'. Sans, ton serveur en UTC tournera à 9h locales. Documenté en commentaire à côté du décorateur.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest 7 :
@nestjs/schedulev0.x, basé surcronv1.@Intervalet@Timeoutrequièrent un nom unique (parfois). - Nest 8 :
@nestjs/schedulev1, API stable.SchedulerRegistryintroduit. - Nest 9 :
cronv2, support timezones natif. Types stricts suraddCronJob. - Nest 10 : passage
cronv3 (breaking) — la classeCronJobest maintenant exportée nominale, params via objet (new CronJob({ cronTime, onTick })).@nestjs/schedulev3+ harmonise. - Nest 11 :
cronv3.x stable, types complets, support AsyncLocalStorage pour propager le contexte dans les jobs. ESM compatible.
Migration cron v2 → v3/v4 :
new CronJob(cronTime, onTick, onComplete, start, timezone)(positional) DEPRECATED → utilise l'objet{ cronTime, onTick, timeZone }, ou la factory typéeCronJob.from({ ... })(évite les castsas anyquand tu passes le job àSchedulerRegistry.addCronJob).nextDates(n)retourne maintenant desDateTimeLuxon au lieu demoment. Adapte.SchedulerRegistry(Nest 10/11) exposedoesExist('cron'|'interval'|'timeout', name): utilise-le pour un add idempotent au lieu de catcher l'exception « already exists ».
⚠️ Pitfalls
- Multi-instances sans lock — LE bug n°1. En dev tu ne le vois pas (1 instance), en prod tu envoies 3 emails. Test prod-like avec 2 replicas + un cron
*/1 * * * *qui log. - Cron expression mal lue —
* */1 * * *≠0 */1 * * *. Le premier tire 60 fois par heure. Utilise un validateur en ligne (crontab.guru) et commit un test qui parse l'expression au boot. - Timezone du serveur — UTC vs locale. Ton cron
0 0 * * *à minuit Paris est en réalité 22h UTC. Précise toujourstimeZone. - Job qui dépasse l'intervalle — un job toutes les 5 min qui prend 7 min. Tu accumules. Vérifie
isRunningavec une garde, ou utilise une queue avecconcurrency: 1. @Intervalaprès un long sleep — pas garanti, le timer JS dérive. Pour des intervalles serrés et critiques, préfère un loop avecsetTimeoutrecalibré ou un cron à la minute.- Pas de logs / observability — un cron qui plante silencieusement. Toujours wrap en try/catch + log + metric (counter Prometheus
cron_runs_total{name, status}). - Schedule dynamique perdu au restart — ajouté en mémoire, oublié. Persiste en DB et recharge au boot dans
onApplicationBootstrap. @Crondans un provider request-scoped — les décorateurs schedule ne fonctionnent qu'en singleton. Le provider DOIT être au scope par défaut.
🧪 Testing
Unit — appelle directement la méthode décorée :
const svc = new TasksService(registryMock);
await svc.dailyDigest();
expect(mailer.sendDigest).toHaveBeenCalled();Test du décorateur — assure-toi que le nom et l'expression sont corrects via reflect-metadata :
import { SchedulerType } from '@nestjs/schedule/dist/enums/scheduler-type.enum';
import { SCHEDULER_NAME, SCHEDULE_CRON_OPTIONS } from '@nestjs/schedule/dist/schedule.constants';
const meta = Reflect.getMetadata(SCHEDULE_CRON_OPTIONS, TasksService.prototype, 'dailyDigest');
expect(meta.cronTime).toBe('0 7 * * *');
expect(meta.timeZone).toBe('Europe/Paris');Integration — utilise jest.useFakeTimers() ou avance le temps via SchedulerRegistry. Plus pratique : extraie la logique métier de la méthode @Cron et teste-la indépendamment. La méthode @Cron devient un simple "wrapper qui delegue".
Test du lock — vérifie qu'avec 2 instances simulées, un seul exécute :
const lock1 = await cronLock.acquire('test', 1000);
const lock2 = await cronLock.acquire('test', 1000);
expect(lock1).toBe(true);
expect(lock2).toBe(false);🎬 Cas d'usage concrets
FinTech — Extraction comptable mensuelle
Qui — Plateforme française de comptabilité en ligne qui doit générer en début de mois les exports FEC (DGFiP), les balances clients et les déclarations TVA. Problème — Le batch tourne 4 heures pour 30 K dossiers. Lancé à 5h du matin pour ne pas saturer la base de production. Ne doit jamais tourner deux fois (sinon double comptabilisation). Comment — @Cron planifié + lock distribué Redis pour la sécurité multi-instances, idempotency par periodKey.
@Injectable()
export class MonthlyClosingJob {
constructor(private lock: RedlockService, private exporter: FecExporter, private repo: ClosingRepo) {}
@Cron('0 5 1 * *', { timeZone: 'Europe/Paris' })
async runClosing() {
const period = format(new Date(), 'yyyy-MM');
const release = await this.lock.acquire(`closing:${period}`, 6 * 3600 * 1000);
if (!release) { Logger.warn(`closing ${period} already running`); return; }
try {
if (await this.repo.isDone(period)) return;
for (const tenant of await this.repo.listTenants()) {
await this.exporter.exportFor(tenant, period);
}
await this.repo.markDone(period);
} finally {
await release();
}
}
}Gains — Zéro double-export, reprise transparente si crash en milieu de batch (continue depuis le dernier tenant marqué), 100% conformité DGFiP.
Immobilier — Relance loyer syndic
Qui — Syndic français qui gère 8 000 lots et doit envoyer des rappels de loyer impayé à J+5, J+10, J+15. Problème — Les relances doivent être envoyées entre 9h et 11h (ouvrabilité), avec template adapté au niveau de retard, et tracées dans le CRM. Comment — Cron toutes les heures pendant la fenêtre 9-11h, scan des leases avec impayés, envoi via worker mail.
@Injectable()
export class RentReminderJob {
constructor(private leases: LeaseService, @InjectQueue('mail') private mail: Queue) {}
@Cron('0 9-11 * * 1-5', { timeZone: 'Europe/Paris' })
async sendReminders() {
const today = new Date();
const overdue = await this.leases.findOverdue(today);
for (const lease of overdue) {
const daysLate = differenceInDays(today, lease.dueDate);
const template = daysLate >= 15 ? 'rent.formal_notice'
: daysLate >= 10 ? 'rent.reminder_2'
: 'rent.reminder_1';
await this.mail.add('send', {
to: lease.tenantEmail,
template,
variables: { tenantName: lease.tenantName, amount: lease.amountDue, daysLate },
idempotencyKey: `rent:${lease.id}:${format(today, 'yyyy-MM-dd')}:${template}`,
});
}
}
}Gains — Taux de paiement à J+15 passé de 88% à 96%, traçabilité dans le CRM, jamais deux relances le même jour grâce à l'idempotencyKey.
Industrie — Maintenance prédictive
Qui — Usine française d'embouteillage avec 80 machines équipées de capteurs IoT. Problème — Calculer chaque nuit les indicateurs de santé machine (MTBF, vibration RMS, dérive thermique) et créer des work orders pour les machines à risque. Comment — Cron quotidien 2h du matin avec timeout interne et reporting Slack en cas de dérive détectée.
@Injectable()
export class PredictiveMaintenanceJob {
@Cron('0 2 * * *', { timeZone: 'Europe/Paris', name: 'predictive-maintenance' })
async analyze() {
const lines = await this.lines.listActive();
const findings: Finding[] = [];
for (const line of lines) {
const metrics = await this.timeseries.aggregate(line.id, '24h');
const risk = this.scorer.score(metrics);
// ⚠️ NE compare PAS des niveaux par ordre alphabétique de string ('critical' < 'warning').
// Mappe explicitement vers un rang numérique.
const RANK = { ok: 0, warning: 1, critical: 2 } as const;
if (RANK[risk.level] >= RANK.warning) {
findings.push({ lineId: line.id, ...risk });
await this.workOrders.create({
lineId: line.id,
type: 'predictive',
priority: risk.level === 'critical' ? 'P1' : 'P2',
findings: risk.signals,
});
}
}
if (findings.length) await this.slack.post('#maintenance', this.format(findings));
}
}Gains — 30% de pannes en moins, planning d'intervention anticipé, équipes prévenues avant d'arriver le matin.
🛠️ Exemple end-to-end
Contexte — Le syndic immobilier ci-dessus déploie un orchestrateur complet de fin de mois : appel de loyer le 1er à 6h, génération des avis d'échéance PDF, envoi email + courrier postal, et au bout de 15 jours déclenchement automatique des relances. On combine @Cron, lock Redis, queue BullMQ pour le travail lourd et reporting Prometheus.
// src/billing/monthly-billing.scheduler.ts
@Injectable()
export class MonthlyBillingScheduler {
private readonly logger = new Logger(MonthlyBillingScheduler.name);
constructor(
private lock: RedlockService,
private leases: LeaseService,
private clock: ClockService,
@InjectQueue('bill-generation') private billQueue: Queue,
@InjectMetric('billing_run_total') private runCounter: Counter,
@InjectMetric('billing_lease_processed') private leaseCounter: Counter,
) {}
@Cron('0 6 1 * *', { timeZone: 'Europe/Paris', name: 'monthly-billing' })
async runMonthlyBilling() {
const period = format(this.clock.now(), 'yyyy-MM');
const lockKey = `billing:monthly:${period}`;
const release = await this.lock.acquire(lockKey, 6 * 3600 * 1000);
if (!release) {
this.logger.warn(`monthly billing ${period} already in progress`);
return;
}
this.runCounter.inc({ period, kind: 'monthly' });
const startedAt = Date.now();
try {
const leases = await this.leases.listActiveAt(this.clock.now());
this.logger.log(`processing ${leases.length} leases for period ${period}`);
for (const lease of leases) {
await this.billQueue.add('generate', {
leaseId: lease.id,
tenantId: lease.tenantId,
period,
amount: lease.monthlyAmount,
}, {
jobId: `bill:${lease.id}:${period}`, // idempotent
attempts: 5,
backoff: { type: 'exponential', delay: 60_000 },
removeOnComplete: { age: 30 * 86_400 },
});
this.leaseCounter.inc({ period });
}
this.logger.log(
`monthly billing ${period} enqueued in ${Date.now() - startedAt}ms`,
);
} finally {
await release();
}
}
}// src/billing/reminder.scheduler.ts
@Injectable()
export class ReminderScheduler {
constructor(
private leases: LeaseService,
private clock: ClockService,
@InjectQueue('reminder') private reminderQueue: Queue,
private lock: RedlockService,
) {}
// Every weekday between 9h and 11h
@Cron('0 9,10,11 * * 1-5', { timeZone: 'Europe/Paris' })
async sendReminders() {
const today = this.clock.startOfDay();
const release = await this.lock.acquire(`reminders:${today.toISOString()}:${new Date().getHours()}`, 30 * 60 * 1000);
if (!release) return;
try {
const overdue = await this.leases.findOverdue(today);
for (const lease of overdue) {
const daysLate = differenceInDays(today, lease.dueDate);
if (![5, 10, 15].includes(daysLate)) continue; // only at trigger points
const tier =
daysLate >= 15 ? 'formal_notice' :
daysLate >= 10 ? 'reminder_2' :
'reminder_1';
await this.reminderQueue.add('send', {
leaseId: lease.id,
tenantId: lease.tenantId,
tier,
daysLate,
dueDate: lease.dueDate.toISOString(),
amount: lease.amountDue,
}, {
jobId: `reminder:${lease.id}:${format(today, 'yyyy-MM-dd')}:${tier}`,
attempts: 3,
});
}
} finally {
await release();
}
}
// Dry-run admin endpoint for ops
async previewReminders(date: Date) {
const overdue = await this.leases.findOverdue(date);
return overdue.map((l) => ({
leaseId: l.id,
tenantId: l.tenantId,
daysLate: differenceInDays(date, l.dueDate),
}));
}
}// src/billing/processors/bill-generation.processor.ts
@Processor('bill-generation', { concurrency: 10 })
export class BillGenerationProcessor extends WorkerHost {
constructor(
private pdf: PdfService,
private storage: DocumentStore,
private bills: BillRepo,
@InjectQueue('mail') private mail: Queue,
@InjectQueue('postal') private postal: Queue,
) { super(); }
async process(job: Job<{ leaseId: string; tenantId: string; period: string; amount: number }>) {
const { leaseId, tenantId, period, amount } = job.data;
if (await this.bills.exists(leaseId, period)) {
return { skipped: true };
}
const pdf = await this.pdf.renderRentInvoice({ leaseId, period, amount });
const key = `bills/${tenantId}/${period}/${leaseId}.pdf`;
await this.storage.put(key, pdf);
const bill = await this.bills.create({ leaseId, tenantId, period, amount, documentKey: key });
await this.mail.add('rent-invoice', {
to: bill.tenantEmail,
template: 'rent.invoice',
attachmentKey: key,
variables: { period, amount, dueDate: bill.dueDate },
});
if (bill.deliveryMode === 'postal') {
await this.postal.add('letter', { documentKey: key, recipientAddress: bill.address });
}
return { billId: bill.id };
}
}// src/billing/billing.module.ts
@Module({
imports: [
ScheduleModule.forRoot(),
BullModule.registerQueue(
{ name: 'bill-generation' }, { name: 'reminder' },
{ name: 'mail' }, { name: 'postal' },
),
PrometheusModule.register(),
],
providers: [
MonthlyBillingScheduler,
ReminderScheduler,
BillGenerationProcessor,
makeCounterProvider({ name: 'billing_run_total', help: 'Billing runs', labelNames: ['period', 'kind'] }),
makeCounterProvider({ name: 'billing_lease_processed', help: 'Leases processed', labelNames: ['period'] }),
],
})
export class BillingModule {}Le lock Redis empêche deux instances de Nest (rolling deploy, autoscaling) de lancer le même batch, jobId rend chaque facture idempotente (rejouer le cron après crash ne crée pas de doublons), la fenêtre horaire 9,10,11 respecte la règle de civilité française, et les compteurs Prometheus permettent de monitorer la complétude du batch depuis Grafana.
🔁 Quand utiliser / éviter
Utilise @nestjs/schedule :
- monolithe ou 1-2 instances
- tâches légères et internes (cleanup, healthcheck, métriques)
- schedules dynamiques (user-defined reminders)
- combiné avec un lock pour la prod
Évite (ou complète) :
- jobs critiques avec retries et observability → push en queue, le cron juste enqueue
- 10+ replicas en k8s → k8s CronJob ou pod dédié, plus simple à raisonner
- jobs longs (> 1 min) → queue + worker, pas dans la main thread
Cron runner (pod dédié) vs Queue-based scheduling :
- Cron runner : un binaire qui ne fait QUE le scheduling, simple, debuggable. Bon défaut.
- Queue-based scheduling (BullMQ
repeat) : centralisé dans Redis, distribué naturellement (Redis = source de vérité), observability dans Bull Board. Préféré quand tu as déjà BullMQ. - k8s CronJob : zéro code Nest pour le timing, mais nouveau pod par run = cold start (peut coûter 2-5 s) et logs séparés.
Règle pratique : si tu as déjà BullMQ, utilise ses repeatable jobs au lieu de @nestjs/schedule pour les jobs réguliers. Garde @nestjs/schedule uniquement pour les internals légers (healthchecks, cleanup intra-process).
🆚 Cron-runner vs Queue-based scheduling vs k8s CronJob
| Critère | @nestjs/schedule seul | + Redis lock | BullMQ repeat | k8s CronJob |
|---|---|---|---|---|
| Setup | trivial | facile | facile | moyen (manifests) |
| Multi-instance safe | ❌ | ✅ | ✅ | ✅ |
| Retries / DLQ | ❌ | ❌ | ✅ | manuel |
| Observability | logs custom | logs custom | Bull Board | k8s events + logs |
| Schedules dynamiques (user-defined) | ✅ in-memory | ✅ avec persistence | ✅ (jobId stable) | ❌ |
| Cold start coût | 0 | 0 | 0 | 2-5 s par run |
| Bon défaut pour... | dev / 1 instance | petite équipe, prod simple | prod sérieuse | clusters k8s mûrs |
🧰 Recette prod recommandée
// 1. ScheduleModule pour healthchecks et internes
@Cron(CronExpression.EVERY_MINUTE)
async healthCheck() { /* metrics, no side effect */ }
// 2. Pour le métier : un cron LEGER protégé par lock qui pousse en queue
@Cron('0 7 * * *', { name: 'daily-digest', timeZone: 'Europe/Paris' })
async tickDailyDigest() {
if (!(await this.lock.acquire(`cron:digest:${today()}`, 23 * 3600_000))) return;
await this.queue.add('digest', { date: today() }, { jobId: `digest:${today()}` });
}
// 3. Le worker (n'importe quelle instance) traite, retry, observe
@Processor('digest')
class DigestProcessor extends WorkerHost {
async process(job: Job) { /* le vrai travail */ }
}Cette stratégie t'offre :
- idempotence (lock + jobId stable),
- retries automatiques (BullMQ),
- observability (Bull Board),
- scalabilité (workers ajoutables sans changer le cron),
- simplicité de debug (le cron lui-même est trivial, le vrai travail est dans la queue).
🔐 Correctness des locks distribués — ce que SETNX ne te dit pas
Le SET key val NX PX ttl est le 90 % use-case, mais un staff engineer sait exactement où il casse. Le piège fondamental : un lock à TTL ne garantit pas l'exclusion mutuelle sous GC pause, network partition, ou clock skew. Il garantit seulement « pas de deadlock permanent ». Ce sont deux choses différentes.
Le scénario qui te réveille à 3h du matin
Pod A acquiert le lock (TTL 30s) ──▶ commence le batch
│
│ ⏸️ GC pause de 35s (ou pod freeze, ou I/O bloquant)
│ pendant ce temps, le TTL Redis EXPIRE
│
Pod B acquiert le MÊME lock ────────▶ commence le MÊME batch
│
▼ Pod A se réveille, croit toujours détenir le lock,
écrit dans la DB ──▶ 💥 double exécution, lock "split-brain"Le lock a expiré côté Redis mais Pod A ne le sait pas : son code tourne encore avec l'hypothèse qu'il détient le lock. Deux pods se croient simultanément leaders.
Trois niveaux de défense (du moins au plus cher)
| Niveau | Mécanisme | Couvre | Coût |
|---|---|---|---|
1. Lock TTL simple (SETNX PX) | premier arrivé gagne, expire seul | crash sans libération, pas de deadlock | trivial |
| 2. + lock extension (watchdog) | renouvelle le TTL toutes les ttl/3 tant qu'on travaille (Redlock extend) | jobs plus longs que le TTL | un timer + une lib |
| 3. + fencing token | Redis renvoie un compteur monotone ; le store aval rejette toute écriture avec un token périmé | GC pause / split-brain réel | le store aval doit comparer le token |
Le fencing token est la seule vraie défense contre le split-brain. Idée (Martin Kleppmann) : le lock incrémente un compteur, et chaque écriture downstream porte ce token. Le store n'accepte que des tokens croissants.
// Acquisition : INCR atomique => token monotone, même si le lock "rebondit" entre pods.
async acquireFenced(key: string, ttlMs: number): Promise<{ token: number } | null> {
const ok = await this.redis.set(key, '1', 'PX', ttlMs, 'NX');
if (ok !== 'OK') return null;
const token = await this.redis.incr(`${key}:fence`); // monotone, ne décroît jamais
return { token };
}
// Écriture downstream : la DB n'applique QUE si le token est plus récent que le dernier vu.
// UPDATE closing SET done = true, fence = $token WHERE period = $p AND fence < $tokenSi Pod A (token=41) reprend après sa GC pause alors que Pod B (token=42) a déjà écrit, l'UPDATE ... WHERE fence < 41 de Pod A ne matche plus rien : son écriture est silencieusement rejetée. Pas de double comptabilisation.
Quand le fencing est overkill
Pour 95 % des crons (digests, cleanups, relances), tu n'as pas besoin de fencing : rends simplement le travail idempotent (jobId BullMQ stable + markDone(period) vérifié au début). Un double-run idempotent est inoffensif — c'est presque toujours moins cher que la complexité d'un fencing token de bout en bout. Réserve le fencing aux écritures non-idempotentes et irréversibles (mouvement comptable, virement bancaire, envoi postal réel).
SETNX simple vs Redlock multi-nœuds
Le SETNX sur un Redis single-master perd le lock sous failover : si le master tombe juste après l'ACK mais avant la réplication, le nouveau master ne connaît pas ton lock → deux pods l'acquièrent. Redlock (quorum sur N≥5 masters indépendants) réduit cette fenêtre, mais reste controversé pour le critique (cf. le débat Kleppmann vs antirez). Règle staff : pour du vraiment critique (argent, conformité), le lock vit dans la même transaction que l'écriture — un SELECT ... FOR UPDATE Postgres ou un advisory lock (pg_try_advisory_xact_lock) donne une exclusion mutuelle ACID sans le théâtre distribué. Redis pour le throughput, Postgres advisory lock pour la correctness absolue.
🤖 Scheduling de jobs IA (Anthropic) — orchestration server-side
Un cas qui revient sans cesse en prod : un cron qui déclenche un batch LLM (résumés nocturnes, classification de tickets, enrichissement de leads, génération de digests). Les pièges du scheduling distribué se cumulent ici avec ceux des appels LLM : non-idempotence (rejouer = re-payer + dupliquer), coût (un retry naïf peut brûler des centaines de $), latence variable (un batch de 10 K résumés ne tient pas dans une fenêtre cron), annulation (rolling deploy qui tue le pod au milieu).
Règle d'or : le cron ne fait JAMAIS l'appel LLM inline. Il enqueue, et un worker BullMQ fait l'appel avec idempotence, retry cost-aware et streaming. Le cron reste trivial et idempotent (lock + jobId stable).
// llm.module.ts — client Anthropic injecté via DI (PAS `new Anthropic()` dans un champ)
import Anthropic from '@anthropic-ai/sdk';
@Module({})
export class LlmModule {
static forRootAsync(): DynamicModule {
return {
module: LlmModule,
providers: [
{
provide: Anthropic,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Anthropic({
apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 4, // retries SDK (429/5xx) avec backoff respectant Retry-After
timeout: 60_000,
}),
},
],
exports: [Anthropic],
global: true,
};
}
}// nightly-summaries.scheduler.ts — le cron ENQUEUE, il n'appelle pas le LLM
@Injectable()
export class NightlySummariesScheduler {
constructor(
private lock: RedlockService,
private docs: DocsRepo,
@InjectQueue('llm-summaries') private queue: Queue,
) {}
@Cron('0 3 * * *', { timeZone: 'Europe/Paris', name: 'nightly-summaries' })
async tick() {
const runId = format(new Date(), 'yyyy-MM-dd'); // clé de génération stable
const release = await this.lock.acquire(`llm:summaries:${runId}`, 60 * 60 * 1000);
if (!release) return; // un autre pod a déjà planifié ce run
try {
for (const doc of await this.docs.listStaleSummaries()) {
await this.queue.add(
'summarize',
{ docId: doc.id, contentHash: doc.contentHash, runId },
{
// idempotence : même doc + même contenu => même job, jamais re-payé
jobId: `summary:${doc.id}:${doc.contentHash}`,
attempts: 3,
backoff: { type: 'exponential', delay: 5_000 },
removeOnComplete: { age: 7 * 86_400 },
},
);
}
} finally {
await release();
}
}
}// llm-summaries.processor.ts — le worker fait l'appel, streaming + cost guard + abort
@Processor('llm-summaries', { concurrency: 4 }) // concurrency = budget de débit, pas plus
export class SummariesProcessor extends WorkerHost {
private readonly logger = new Logger(SummariesProcessor.name);
constructor(
private anthropic: Anthropic, // injecté, pas instancié
private summaries: SummaryRepo,
private costGuard: CostGuard, // arrête le batch si budget jour dépassé
) {
super();
}
async process(job: Job<{ docId: string; contentHash: string; runId: string }>) {
const { docId, contentHash } = job.data;
// Idempotence applicative : si déjà résumé pour CE hash, on ne re-paie pas.
const existing = await this.summaries.find(docId);
if (existing?.contentHash === contentHash) return { skipped: true };
await this.costGuard.assertWithinDailyBudget(); // throw -> retry plus tard
// AbortController câblé sur l'annulation propre du process (SIGTERM rolling deploy)
// ET sur le timeout métier du job. BullMQ n'expose pas un signal d'annulation par job
// out-of-the-box : la bonne source de vérité est (1) le SIGTERM du pod, (2) un timeout
// dur que TOI tu poses. N'invente pas un `queue.on('failed')` — ça écoute les ÉCHECS
// d'AUTRES jobs, pas l'annulation de CELUI-ci.
const ac = new AbortController();
const onSigterm = () => ac.abort();
process.once('SIGTERM', onSigterm);
const hardTimeout = setTimeout(() => ac.abort(), 55_000); // < lock TTL, < timeout SDK
const content = await this.docs.read(docId);
let text = '';
try {
const stream = this.anthropic.messages.stream(
{
model: 'claude-haiku-4-5', // batch volumineux + tâche simple => haiku
max_tokens: 1024,
messages: [{ role: 'user', content: `Résume en 3 points:\n\n${content}` }],
},
{ signal: ac.signal }, // annulation propagée jusqu'au socket HTTP
);
for await (const ev of stream) {
if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
text += ev.delta.text;
await job.updateProgress({ chars: text.length }); // partial-output observable
}
}
const final = await stream.finalMessage();
await this.costGuard.record(final.usage); // input+output tokens => $
await this.summaries.upsert(docId, { contentHash, text });
return { tokens: final.usage.output_tokens };
} catch (err) {
if (ac.signal.aborted) {
// Annulation propre : le SDK lève une APIUserAbortError. On ne compte PAS ça comme
// un échec dur (ne pas brûler une `attempt`). On laisse BullMQ re-tenter au prochain
// worker : jobId stable + check `contentHash` => zéro doublon, zéro re-paiement.
this.logger.warn(`summary ${docId} aborted, ${text.length} chars partial`);
throw new Error('aborted');
}
throw err;
} finally {
clearTimeout(hardTimeout);
process.off('SIGTERM', onSigterm);
}
}
}Ce qu'un staff engineer surveille ici :
| Préoccupation | Mécanisme | Pourquoi |
|---|---|---|
| Idempotence (ne pas re-payer) | jobId = summary:${id}:${contentHash} + check applicatif | Rejouer le cron après crash ne duplique ni ne re-facture |
| Coût | CostGuard (budget/jour) + usage enregistré par appel | Un retry storm sur 429 peut coûter des centaines de $ |
| Retries | maxRetries SDK (transport) + attempts BullMQ (métier) | Deux niveaux distincts : 429/5xx vs erreur métier |
| Annulation | AbortController + signal passé à .stream() | Rolling deploy / TaskStop ne laisse pas un appel pendre 60 s |
| Débit / quota | concurrency du worker = throttle naturel | Évite de dépasser le rate-limit org Anthropic |
| Choix de modèle | claude-haiku-4-5 pour le volume, claude-sonnet-4-6 si qualité requise, claude-opus-4-8 pour le raisonnement lourd | Le batch nocturne optimise le coût/token |
| Observability | job.updateProgress + usage en metric Prometheus | llm_tokens_total{model,status}, llm_cost_eur |
Anti-pattern classique : @Cron('0 3 * * *') async tick() { for (doc of docs) await anthropic.messages.create(...) }. En multi-instances → N batches en parallèle (× le coût). Sans lock, sans queue, sans abort : un deploy tue le pod à mi-parcours, et le prochain run recommence tout depuis zéro (re-paiement intégral). Le cron-enqueue + worker idempotent élimine ces trois failure modes d'un coup.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice suppose un projet Nest 11 +
@nestjs/schedulev4 + BullMQ + Redis.
1. Cron observable (échauffement)
Objectif — Un @Cron(CronExpression.EVERY_30_SECONDS) qui incrémente un compteur Prometheus cron_runs_total{name,status} et n'avale jamais une exception. Indice — Wrap la logique dans un try/catch ; status='success'|'error' en label ; assure-toi que le catch log ET incrémente avant de (ne pas) rethrow — un cron qui throw ne crashe pas le process mais empoisonne tes logs.
2. Garde anti-chevauchement (overlap guard)
Objectif — Un job toutes les 10 s dont le traitement dure parfois 25 s ne doit jamais tourner en parallèle avec lui-même (intra-process). Indice — Drapeau private running = false + early-return si true, finally { this.running = false }. Question piège : pourquoi ce drapeau ne suffit PAS en multi-instances ? (Il ne couvre qu'un seul process — il faut un lock Redis pour le cross-pod.)
3. Leader election distribué + idempotence
Objectif — Sur 3 replicas, un cron quotidien 0 7 * * * ne doit s'exécuter qu'une fois ET ne jamais dupliquer son travail même si le pod élu crashe à mi-parcours. Indice — SET key val NX PX ttl pour l'élection (TTL < intervalle). Pour l'idempotence du travail : jobId BullMQ stable (digest:${date}) + un markDone(period) en DB vérifié au début. Teste en simulant 3 appels concurrents + un kill au milieu (le re-run reprend depuis le dernier item marqué, ne refait pas les précédents).
4. Schedules dynamiques persistés (production-grade)
Objectif — Un endpoint POST /reminders { cron, payload } qui crée un cron utilisateur, le persiste en DB, et les recharge TOUS au boot via onApplicationBootstrap. DELETE /reminders/:id retire le job du SchedulerRegistry ET de la DB. Indice — registry.addCronJob(name, new CronJob({ cronTime, onTick, timeZone, start: false })) puis .start(). Au boot, itère la DB. Piège : valide l'expression cron AVANT de persister (sinon un mauvais cron au reload crashe tout le bootstrap) — entoure chaque reload d'un try/catch et marque le cron invalid plutôt que de tout faire tomber.
5. Migrer un cron LLM coûteux vers cron-enqueue idempotent
Objectif — On te donne un @Cron qui appelle anthropic.messages.create() inline dans une boucle. Refactore en cron-léger-qui-enqueue + worker BullMQ, avec idempotence par contentHash, CostGuard journalier et AbortController câblé sur l'annulation du job. Indice — jobId = summary:${docId}:${contentHash} ; injecte Anthropic via forRootAsync (jamais new dans un champ) ; concurrency du worker = throttle de débit ; passe { signal } à .stream(). Vérifie qu'un re-run sans changement de contenu ne fait AUCUN appel API (skip).
6. Casse-le puis répare (chaos)
Objectif — Déclenche les 3 failure modes du scheduling distribué et prouve qu'ils sont colmatés : (a) double exécution multi-pod, (b) dérive timezone, (c) job qui dépasse son intervalle et s'empile. Indice — (a) lance 2 instances avec un cron */1 * * * * qui log le hostname → sans lock, 2 lignes par minute ; ajoute le lock → 1 ligne. (b) déploie sans timeZone sur un serveur UTC, observe le décalage de 2h en été (CEST), corrige avec timeZone: 'Europe/Paris'. (c) cron 5 s + await sleep(12_000) → empilement ; ajoute l'overlap guard + queue concurrency: 1. Documente chaque avant/après avec les logs.
7. Split-brain : casse le lock TTL, répare-le au fencing (expert)
Objectif — Reproduis le scénario GC-pause où DEUX pods se croient leaders simultanément, prouve la double écriture, puis colmate avec un fencing token de bout en bout (Redis INCR + UPDATE ... WHERE fence < $token). Indice — Simule la pause sans vrai GC : acquire(lock, ttl=2000) puis await sleep(3000) (le TTL expire pendant ce sleep) AVANT d'écrire ; un second worker acquiert le même lock dans l'intervalle → les deux écrivent. Avec fencing : Pod A porte token 41, Pod B token 42 ; l'UPDATE closing SET done=true, fence=$t WHERE period=$p AND fence < $t du retardataire (41) ne matche aucune ligne → écriture rejetée silencieusement. Question piège : pourquoi un simple « re-check du lock juste avant d'écrire » ne suffit PAS ? (Le check et l'écriture ne sont pas atomiques — le lock peut expirer dans la fenêtre entre les deux ; seul le fencing porté DANS l'écriture est correct.)
🎤 En entretien
Q : « Tu as un cron @nestjs/schedule qui envoie un email quotidien. En prod sur 4 replicas k8s, les clients reçoivent 4 emails. Pourquoi, et comment tu corriges ? » R : Chaque replica démarre son propre ScheduleModule en mémoire → 4 timers indépendants tirent à la même heure. Deux familles de fix : (1) leader election — un SET key NX PX ttl Redis atomique, seul le premier pod gagne le tick, les autres no-op ; (2) externaliser le timing — k8s CronJob ou BullMQ repeatable (Redis = source de vérité unique). En pratique : cron-léger + lock qui enqueue, et un worker idempotent fait le travail réel.
Q : « Lock Redis acquis, le pod crashe avant de libérer. Que se passe-t-il ? Et si le job dure plus longtemps que le TTL ? » R : Le TTL garantit que le lock expire seul → pas de deadlock permanent. Mais si le job dépasse le TTL, un AUTRE pod peut acquérir le lock pendant que le premier tourne encore → double exécution. Parades : TTL généreux (> durée max du job), ou lock extension (renouveler le TTL périodiquement tant qu'on travaille, façon Redlock extend), ou rendre le travail idempotent (jobId stable) pour que le double-run soit inoffensif. Le single Redis SETNX n'est pas safe sous failover maître/réplica — pour du critique, Redlock multi-nœuds ou un lock transactionnel en DB.
Q : « Le lock extension protège-t-il contre TOUS les cas de double exécution ? » R : Non. L'extension couvre le cas « job lent ». Elle ne couvre PAS la GC pause / pod freeze : si le process gèle 35 s, le TTL expire, un autre pod prend le lock, puis le premier se réveille en croyant toujours le détenir et écrit → split-brain. La seule défense réelle contre ça est un fencing token : un compteur monotone (INCR) porté par chaque écriture, et un store aval qui rejette tout token périmé (WHERE fence < $token). En pratique, sauf écriture irréversible (argent, envoi postal), on préfère rendre le travail idempotent (jobId stable + markDone vérifié) — un double-run idempotent est inoffensif et bien moins coûteux que le fencing de bout en bout. Pour la correctness ACID absolue : pg_try_advisory_xact_lock dans la même transaction que l'écriture.
Q : « Pourquoi préférer un cron-qui-enqueue à un cron qui fait le travail inline ? » R : Le cron inline n'a ni retry, ni DLQ, ni observability, ni backpressure, et bloque le thread pour un job long. En déléguant à BullMQ : retries avec backoff exponentiel, idempotence par jobId, concurrency/throttle, Bull Board pour l'observability, et scalabilité (ajouter des workers sans toucher au cron). Le cron redevient trivial (un acquire + add) donc trivialement testable. C'est la séparation timing / exécution.
Q : « Tu schedules un batch LLM nocturne sur 50 000 documents. Quels sont tes trois risques principaux et comment tu les gères ? » R : (1) Coût/idempotence — un re-run après crash ne doit pas re-payer ; jobId = doc:${id}:${contentHash} + check applicatif → skip si déjà fait pour ce contenu. (2) Rate-limit/débit — concurrency du worker borne le débit sous le quota org Anthropic ; les 429 sont gérés par maxRetries du SDK (respecte Retry-After), pas par une boucle naïve. (3) Annulation/coût mort — AbortController câblé sur l'annulation du job, { signal } passé à .stream(), pour qu'un rolling deploy ne laisse pas 4 appels pendre 60 s. Bonus : CostGuard journalier qui throw → retry plus tard quand le budget se réinitialise, et choix de modèle adapté (claude-haiku-4-5 pour le volume, claude-sonnet-4-6/claude-opus-4-8 réservés aux tâches qui le justifient).
🔗 Liens
@nestjs/scheduledocs : https://docs.nestjs.com/techniques/task-schedulingcronnpm : https://github.com/kelektiv/node-cron- crontab.guru : https://crontab.guru (validateur visuel)
- BullMQ repeatable jobs : https://docs.bullmq.io/guide/jobs/repeatable
- Kubernetes CronJob : https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
- Redlock algorithm (locks distribués robustes) : https://redis.io/docs/manual/patterns/distributed-locks/
- Article "Distributed cron in microservices" — Shopify engineering
- "Stop using cron for distributed jobs" — Inngest blog