Skip to content

Scheduling & Cron (@nestjs/schedule)

TL;DR@nestjs/schedule enveloppe cron, setInterval, setTimeout dans des décorateurs (@Cron, @Interval, @Timeout) et expose un SchedulerRegistry pour 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

ts
// app.module.ts
import { ScheduleModule } from '@nestjs/schedule';

@Module({
  imports: [ScheduleModule.forRoot()],
})
export class AppModule {}

Cron / Interval / Timeout

ts
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)

ts
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

ts
@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

  1. 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.
  2. Leader election RedisSET key value NX PX ttl atomique. Le premier pod gagne, les autres no-op. TTL court < intervalle de cron.
  3. Dedicated scheduler pod — un seul replica scheduler dans ton deployment, distinct du replica api. Plus simple que le lock. Risque : SPOF — accepte-le, ton scheduling repartira au prochain redéploiement.
  4. 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.
  5. 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.
  6. 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/schedule v0.x, basé sur cron v1. @Interval et @Timeout requièrent un nom unique (parfois).
  • Nest 8 : @nestjs/schedule v1, API stable. SchedulerRegistry introduit.
  • Nest 9 : cron v2, support timezones natif. Types stricts sur addCronJob.
  • Nest 10 : passage cron v3 (breaking) — la classe CronJob est maintenant exportée nominale, params via objet (new CronJob({ cronTime, onTick })). @nestjs/schedule v3+ harmonise.
  • Nest 11 : cron v3.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ée CronJob.from({ ... }) (évite les casts as any quand tu passes le job à SchedulerRegistry.addCronJob).
  • nextDates(n) retourne maintenant des DateTime Luxon au lieu de moment. Adapte.
  • SchedulerRegistry (Nest 10/11) expose doesExist('cron'|'interval'|'timeout', name) : utilise-le pour un add idempotent au lieu de catcher l'exception « already exists ».

⚠️ Pitfalls

  1. 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.
  2. 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.
  3. Timezone du serveur — UTC vs locale. Ton cron 0 0 * * * à minuit Paris est en réalité 22h UTC. Précise toujours timeZone.
  4. Job qui dépasse l'intervalle — un job toutes les 5 min qui prend 7 min. Tu accumules. Vérifie isRunning avec une garde, ou utilise une queue avec concurrency: 1.
  5. @Interval après un long sleep — pas garanti, le timer JS dérive. Pour des intervalles serrés et critiques, préfère un loop avec setTimeout recalibré ou un cron à la minute.
  6. Pas de logs / observability — un cron qui plante silencieusement. Toujours wrap en try/catch + log + metric (counter Prometheus cron_runs_total{name, status}).
  7. Schedule dynamique perdu au restart — ajouté en mémoire, oublié. Persiste en DB et recharge au boot dans onApplicationBootstrap.
  8. @Cron dans 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 :

ts
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 :

ts
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 :

ts
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.

ts
@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.

ts
@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.

ts
@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.

ts
// 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();
    }
  }
}
ts
// 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),
    }));
  }
}
ts
// 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 };
  }
}
ts
// 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 lockBullMQ repeatk8s CronJob
Setuptrivialfacilefacilemoyen (manifests)
Multi-instance safe
Retries / DLQmanuel
Observabilitylogs customlogs customBull Boardk8s events + logs
Schedules dynamiques (user-defined)✅ in-memory✅ avec persistence✅ (jobId stable)
Cold start coût0002-5 s par run
Bon défaut pour...dev / 1 instancepetite équipe, prod simpleprod sérieuseclusters k8s mûrs

🧰 Recette prod recommandée

ts
// 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)

NiveauMécanismeCouvreCoût
1. Lock TTL simple (SETNX PX)premier arrivé gagne, expire seulcrash sans libération, pas de deadlocktrivial
2. + lock extension (watchdog)renouvelle le TTL toutes les ttl/3 tant qu'on travaille (Redlock extend)jobs plus longs que le TTLun timer + une lib
3. + fencing tokenRedis renvoie un compteur monotone ; le store aval rejette toute écriture avec un token périméGC pause / split-brain réelle 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.

ts
// 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 < $token

Si 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).

ts
// 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,
    };
  }
}
ts
// 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();
    }
  }
}
ts
// 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éoccupationMécanismePourquoi
Idempotence (ne pas re-payer)jobId = summary:${id}:${contentHash} + check applicatifRejouer le cron après crash ne duplique ni ne re-facture
CoûtCostGuard (budget/jour) + usage enregistré par appelUn retry storm sur 429 peut coûter des centaines de $
RetriesmaxRetries SDK (transport) + attempts BullMQ (métier)Deux niveaux distincts : 429/5xx vs erreur métier
AnnulationAbortController + signal passé à .stream()Rolling deploy / TaskStop ne laisse pas un appel pendre 60 s
Débit / quotaconcurrency du worker = throttle naturelÉvite de dépasser le rate-limit org Anthropic
Choix de modèleclaude-haiku-4-5 pour le volume, claude-sonnet-4-6 si qualité requise, claude-opus-4-8 pour le raisonnement lourdLe batch nocturne optimise le coût/token
Observabilityjob.updateProgress + usage en metric Prometheusllm_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/schedule v4 + 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. IndiceSET 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. Indiceregistry.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. IndicejobId = 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ébitconcurrency 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 mortAbortController 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

Bibliothèque tech perso — Achref