06 sept. 2025·8 min de lecture

Tâches cron sans serveur : éviter les chevauchements et détecter les échecs silencieux

Rendez vos tâches cron sans serveur fiables : choisissez un planificateur adapté, bloquez les exécutions concurrentes avec des verrous et ajoutez un signal de dernière exécution avec alertes.

Tâches cron sans serveur : éviter les chevauchements et détecter les échecs silencieux

Le problème : chevauchements et échecs silencieux

La plupart des tâches cron sans serveur échouent de deux manières prévisibles : elles s'exécutent deux fois en même temps, ou elles s'arrêtent et personne ne s'en rend compte.

Un chevauchement se produit quand la prochaine exécution programmée démarre avant que la précédente ne se termine. Dans les systèmes réels, cela se traduit par des factures en double, des e-mails répétés, des paiements doublés ou des imports qui écrivent deux fois les mêmes lignes. Même si votre code est en grande partie idempotent, les chevauchements posent problème : ils gaspillent des quotas, débitent deux fois des cartes et gardent des verrous plus longtemps que prévu.

Les échecs silencieux sont pires parce qu'ils donnent l'impression que tout va bien. Une tâche peut s'arrêter parce qu'un déploiement a supprimé le schedule, une modification de permissions a bloqué l'accès à la base, un secret a expiré, des quotas ont été atteints, ou une mise à jour de plateforme a désactivé un trigger. Les anciens logs peuvent rester présents, donc tout paraît normal jusqu'à ce qu'un client signale des données manquantes.

« Ça a fonctionné une fois » n'est pas un plan de fiabilité. Qu'une tâche ait tourné hier ne prouve rien pour demain, surtout quand la config, les permissions, les secrets et les runtimes changent sans toucher au code.

Ce que vous voulez est simple et mesurable :

  • Aucune exécution concurrente (une exécution possède le travail, les autres se retirent)
  • Détection rapide quand les exécutions cessent (un signal clair de « dernière exécution », plus une alerte quand il est périmé)

Considérez la planification comme une infrastructure de production et vous arrêterez de chasser des bugs intermittents plus tard.

Choisir un planificateur adapté

Tous les planificateurs ne se valent pas, et ça compte dès que des personnes dépendent de vos jobs.

Les planificateurs basés sur des événements déclenchent une action à (peu près) un moment donné et transmettent le travail à une fonction ou un endpoint. Ils sont simples et peu coûteux, mais la livraison est souvent en « best effort » sauf si vous ajoutez retries, dead-letter et monitoring.

Les planificateurs basés sur une file mettent en file un message « exécuter ce job ». Ce saut supplémentaire est utile parce que les files offrent en général plus de contrôle sur les retries, le backpressure et la visibilité. Si votre job est lourd, lent ou en rafales, une file rend les échecs plus faciles à voir et à récupérer.

Parmi les options courantes : AWS EventBridge Scheduler (ou CloudWatch), GCP Cloud Scheduler (souvent avec Pub/Sub ou Cloud Tasks), Azure Functions Timer Trigger (ou Logic Apps), et les planificateurs CI comme GitHub Actions pour des tâches de maintenance légères.

Une façon pratique de choisir consiste à répondre à quelques questions :

  • À quelle fréquence ça tourne, et la minute exacte est-elle importante ?
  • Combien de temps une exécution peut-elle prendre au maximum (secondes vs heures) ?
  • Que doit-il se passer en cas d'échec : retry, alerte, ou les deux ?
  • Quelles permissions sont nécessaires (base de données, secrets, API tierces) ?
  • Faut-il rattraper une exécution manquée ?

Si vous avez besoin de garanties fortes, évitez les configurations « fire and forget » sans retries, sans dead-letter et sans alerte quand rien ne s'exécute. C'est comme ça que les jobs s'arrêtent silencieusement pendant des jours.

Définir votre modèle d'exécution avant de coder

Beaucoup de problèmes de fiabilité commencent avant le planificateur. Ils commencent par une définition floue de ce qu'est une « exécution ».

Décidez ce qui compte comme une exécution et notez-le. Est-ce « tout depuis la dernière exécution », « tous les enregistrements d'hier », ou « un batch id créé à 02:00 » ? Ce choix unique influence la façon dont vous verrouillez, retryez et récupérez.

Rendre le « s'exécuter deux fois » sûr quand c'est possible

Même avec de bons verrous, supposez qu'une exécution puisse se produire deux fois à cause de retries, timeouts ou rejouements manuels. Visez un travail idempotent : la même entrée doit mener au même résultat final.

Un pattern simple consiste à stocker une clé d'exécution (par exemple 2026-01-20) et à enregistrer les items traités sous cette clé. Si la même clé s'exécute à nouveau, vous sautez les items déjà complétés au lieu de répéter les effets secondaires.

Séparer le trigger du worker

Considérez le trigger comme un démarreur léger et mettez le vrai travail dans un worker. Le trigger ne doit que calculer la clé d'exécution, tenter de réclamer le run et déléguer.

Cela sépare la logique métier des garde-fous de fiabilité et facilite le changement de planificateur plus tard.

Avant de coder, définissez les résultats attendus :

  • Succès : quelles données sont définitivement correctes et où sont-elles enregistrées ?
  • Échec : que faut-il rollbacker et que peut-on retry ?
  • Succès partiel : qu'est-ce qui peut rester et comment reprendre ?
  • Timeout : quel état peut rester en suspens ?

Planifiez votre garde-concurrence (stratégie de verrou)

Si vous exécutez des cron serverless, supposez que deux mauvaises journées arriveront : un job prend trop de temps et le schedule suivant se déclenche quand même, ou une fonction retry après un timeout et vous avez deux copies. Une garde-concurrence est la petite pièce qui rend ces journées ennuyeuses plutôt que catastrophiques.

Commencez par choisir où le verrou vit. Choisissez quelque chose que votre job peut lire/écrire rapidement, avec un comportement clair « un seul gagne ».

Choisir le store du verrou

Choix courants :

  • Une seule ligne en base (idéal si vous utilisez déjà Postgres/MySQL et pouvez faire une mise à jour atomique)
  • Redis (rapide et pratique pour des verrous courts, mais assurez-vous de sa haute disponibilité)
  • Un bail sur du stockage d'objets (un blob/fichier créé avec "if not exists" ; simple, mais parfois plus lent)

Ensuite, décidez ce que la clé de verrou représente. Une clé pratique inclut souvent le nom du job et la fenêtre horaire prévue, par exemple billing-sync:2026-01-20T02:00Z. Cela bloque les doublons pour le même créneau sans empêcher l'exécution du jour suivant.

Mettez toujours un TTL (expiration). Le TTL vous protège quand une exécution plante en cours ou que la plateforme tue le processus. Réglez-le un peu plus long que votre pire temps d'exécution, pas que votre moyenne.

Enfin, décidez ce qui se passe en cas de conflit :

  • Skipper (sûr pour un travail idempotent, mais vous risquez de manquer une exécution)
  • Rescheduler (meilleure couverture, mais peut créer des pics de trafic)
  • Echouer bruyamment (idéal pour les jobs critiques où manquer un run est pire que le bruit)

Pas à pas : empêcher les exécutions concurrentes avec un verrou

Les chevauchements surviennent quand votre planificateur déclenche deux fois, ou quand une exécution prend plus longtemps que prévu. En serverless, la solution la plus simple est un verrou partagé stocké hors de la fonction (ligne en base, clé Redis ou KV cloud). Une exécution gagne le verrou ; les autres quittent.

1) Utiliser un flux de verrou clair

Gardez le flux prévisible :

  • Acquérir le verrou (création atomique ou mise à jour conditionnelle)
  • Si le verrou est pris, sortir rapidement
  • Exécuter le job
  • Relâcher le verrou, mais seulement si vous en êtes toujours le propriétaire

2) Ajouter un token propriétaire et toujours relâcher

Un token propriétaire empêche la Run B de relâcher le verrou de la Run A. Relâchez toujours dans un bloc finally pour que les erreurs ne laissent pas un verrou permanent.

import crypto from "crypto";

export async function handler() {
  const lockKey = "nightly-report";
  const owner = crypto.randomUUID();
  const ttlSeconds = 15 * 60; // lock safety window

  const acquired = await acquireLock({ lockKey, owner, ttlSeconds });
  if (!acquired) return { status: "skipped", reason: "lock_taken" };

  try {
    await doWork();
    return { status: "ok" };
  } finally {
    await releaseLock({ lockKey, owner }); // only release if owner matches
  }
}

Un bon acquireLock est atomique et définit une expiration (TTL) pour qu'une exécution morte ne bloque pas indéfiniment.

3) Tester avec un chevauchement forcé

Déclenchez deux runs en même temps (invoquez manuellement deux fois, ou réduisez temporairement l'intervalle). L'un doit s'exécuter ; l'autre doit logger "skipped: lock_taken". Si les deux tournent, votre écriture de verrou n'est pas vraiment atomique ou votre vérification du propriétaire manque.

Pas à pas : ajouter un contrôle "dernière exécution" (heartbeat)

Du prototype à la production
Transformez une fonction cron fragile en un trigger léger + worker fiable.

Un heartbeat est un petit enregistrement "je suis passé" que votre job écrit à chaque fois qu'il se termine (succès ou échec). Il transforme les échecs silencieux en alertes, ce qui compte en serverless où il n'y a pas de processus toujours actif pour remarquer un arrêt.

1) Choisir où stocker le "dernier passage"

Choisissez un endroit facile à écrire, rapide à lire et peu susceptible d'être indisponible en même temps que votre planificateur :

  • Une table en base
  • Une clé dans un store clé-valeur (simple job_name -> last_run)
  • Un système de métriques / série temporelle

2) Enregistrer les bons champs

Ne stockez pas seulement un timestamp. Enregistrez assez pour déboguer sans fouiller les logs d'abord.

job_name, run_id, started_at, finished_at, status, duration_ms, error_snippet

Une règle pratique : écrire une fois au démarrage (status=running), puis mettre à jour à la fin (status=success ou failed). Cela permet aussi de détecter un état "bloqué en running".

3) Définir un seuil et des règles d'alerte

Réglez le seuil de "heartbeat manquant" à environ 2x votre intervalle attendu. Si un job tourne toutes les 15 minutes, alertez s'il n'y a pas de heartbeat réussi en 30 minutes.

Séparez les types d'alerte :

  • Heartbeat manquant : pas de succès dans le seuil (probablement pas d'exécution)
  • Échecs répétés : les N dernières exécutions ont échoué (le job tourne mais le travail est cassé)

Exemple : une sync de facturation nocturne doit tourner à 02:00 et finir en 5 minutes. Alertez s'il n'y a pas de succès à 02:15. Utilisez une alerte différente si elle a tourné mais a échoué trois nuits d'affilée.

Logs et alertes qui servent vraiment

Quand les cron serverless se comportent mal, le premier problème n'est généralement pas le planificateur. C'est que personne ne peut répondre rapidement à trois questions : a-t-il démarré, a-t-il fini et pourquoi a-t-il été sauté ?

Donnez à chaque exécution un run id cohérent (par exemple : timestamp + suffixe aléatoire court). Loggez-le au démarrage et incluez-le dans chaque ligne de log pour suivre une exécution de bout en bout.

Loggez aussi quand une exécution n'a pas lieu volontairement. Un run sauté n'est pas la même chose qu'un run en échec, mais c'est un signal important. Si la tâche n'a pas tourné parce qu'elle n'a pas obtenu le verrou, dites-le clairement et incluez la clé de verrou (et le propriétaire si vous l'avez).

Gardez les logs cohérents :

  • Start : run id, nom du job, temps du trigger, version/commit, entrées importantes
  • Skip : run id, nom du job, raison du skip (conflit de lock, scheduler désactivé, feature flag off)
  • Finish : run id, status (ok/failed), durée, compteurs (items traités, erreurs)
  • Failure : run id, type d'erreur, contexte sûr, et ce qui a déjà été fait

Les alertes doivent surveiller des motifs, pas juste des erreurs isolées. Un pic de durée peut indiquer qu'une API en amont est lente. Trop de skips peut signifier un verrou bloqué. L'absence totale d'exécutions pointe souvent vers une dérive de permissions du scheduler ou un déploiement qui a supprimé le trigger.

Faites en sorte que chaque alerte soit actionnable. Incluez la dernière exécution réussie, le prochain horaire attendu et la première chose à vérifier (enregistrement de verrou, statut du scheduler, déploiement récent).

Retries, timeouts et reprise en toute sécurité

Rendre les logs réellement utiles
Nous ajoutons des run IDs, raisons de skip et logs cohérents pour faciliter les enquêtes.

Les retries aident, mais ils créent aussi des chevauchements. Beaucoup de planificateurs relancent automatiquement si votre fonction retourne une erreur ou timeoute. Sans verrou distribué (ou si vous le relâchez trop tôt), un retry peut démarrer pendant que l'exécution initiale travaille encore.

Les timeouts aggravent le problème. En serverless, la plateforme peut arrêter votre code en cours quand vous atteignez une limite de temps. Vous n'avez peut-être pas la chance de nettoyer, et vous ne savez pas quelles étapes sont finies. Si le scheduler retrye, vous pouvez renvoyer des e-mails en double, facturer deux fois ou écrire des doublons.

Une approche plus sûre est de rendre chaque run reprenable et idempotent. Pensez en checkpoints, pas en une grosse fonction "tout faire". Par exemple, un job d'émission de factures nocturne peut stocker un marqueur de progression comme "traité jusqu'à invoice_id 18420" et continuer à partir de là.

Garde-fous qui empêchent les retries et les rattrapages de causer des dégâts :

  • Garder le verrou pendant tout le run. Le relâcher seulement quand vous avez vraiment fini.
  • Enregistrer un run_id et des marqueurs de progression pour qu'un retry puisse reprendre au lieu de tout recommencer.
  • Diviser le travail en petits batches avec une vérification "déjà traité" par item.
  • Ajouter un mode backfill contrôlé qui traite les fenêtres manquées une par une.

Les backfills comptent parce que les schedules glissent. Si la run d'hier a échoué, la run d'aujourd'hui ne devrait pas automatiquement traiter deux jours d'un coup sauf si votre système peut le gérer. Une règle simple : « traiter la plus ancienne fenêtre manquante d'abord, puis s'arrêter ».

Erreurs courantes et corrections faciles

La plupart des pannes en production viennent de quelques choix qui semblent acceptables dans un prototype.

  • TTL plus court que le job. Votre promesse « une seule exécution » se casse dès qu'une exécution est lente. Correction : fixez le TTL sur le pire temps d'exécution attendu + marge, et rafraîchissez-le pendant que le job vit.
  • TTL trop long. Une exécution plantée peut bloquer le schedule pendant des heures. Correction : garder un TTL raisonnable, relâcher dans finally et utiliser un token propriétaire pour que personne d'autre ne débloque par accident.
  • Verrous en mémoire. En serverless, chaque run peut atterrir sur une instance différente, donc des flags en mémoire ne servent à rien. Correction : utilisez un store partagé (ligne DB, Redis ou KV managé).
  • Supposer "exactement une fois" sans idempotence. Les retries et la livraison au moins une fois vous surprendront. Correction : écrire avec des clés uniques, upserts ou vérification run-id avant les effets de bord.
  • Utiliser les logs comme heartbeat. Les logs sont super pour le debugging, mais pénibles à alerter. Correction : écrire une trace de dernière exécution dans un endroit interrogeable (DB/KV/métriques).

Une cause facile à rater d'échec silencieux est la dérive des permissions. Le scheduler se déclenche encore, mais le worker ne peut plus lire des secrets, écrire dans le stockage ou appeler une API après une modification.

Checklist rapide avant mise en production

Avant de faire confiance aux cron serverless en production, faites un passage ciblé sur les pannes ennuyeuses : un scheduler désactivé, un verrou qui expire trop tôt, ou un heartbeat que personne ne vérifie.

  • Confirmez que le schedule est activé dans le bon environnement, et que l'identité runtime peut lire les secrets, écrire dans votre DB/queue et émettre des logs.
  • Notez votre garde-concurrence : format de clé de verrou, où il est stocké, TTL et comportement en cas de conflit (skip, reschedule ou fail).
  • Validez le TTL avec des temps réels. Si le job prend parfois 12 minutes, un verrou de 10 minutes créera des chevauchements.
  • Stockez un signal "dernière exécution réussie" dans un endroit que vous pouvez interroger rapidement en cas d'incident, et incluez le statut (pas seulement un timestamp).
  • Faites deux tests intentionnels : (1) forcer un échec pour confirmer que les alertes atteignent un humain, et (2) forcer un chevauchement pour confirmer que la seconde exécution est bloquée et bien loggée.

Un test d'overlap simple : démarrez un run avec un sleep volontaire au milieu, puis déclenchez un second run. Si vous ne voyez pas un message clair « lock held, exiting », votre garde n'est pas encore fiable.

Exemple : un job nocturne qui ne doit jamais s'exécuter deux fois

Obtenir une vérification experte
Outils assistés par IA + vérification humaine, avec un taux de réussite de 99 % sur les corrections.

Un cas fréquent et pénible : un "export de rapport" nocturne tourne à 02:00, génère des PDF et les envoie par e-mail aux clients. Après un déploiement, le scheduler se déclenche deux fois (ou un retry survient) et certains clients reçoivent des e-mails en double. Rien n'est « down », mais la confiance chute vite.

La correction tient en deux petites pièces : un verrou pour empêcher le chevauchement et un heartbeat pour détecter les arrêts silencieux.

D'abord, le job acquiert un verrou distribué (par exemple une ligne en base ou une clé dans un store managé) avec un TTL supérieur au temps d'exécution attendu. Si le verrou est déjà détenu, la seconde invocation s'arrête avant d'envoyer quoi que ce soit.

Un flux pratique :

  • Tenter d'acquérir le verrou nightly-export avec TTL 45 minutes
  • Si le verrou existe, logger "skipped: already running" et arrêter
  • Si le verrou est acquis, générer l'export et envoyer les e-mails
  • Relâcher le verrou (le TTL est une sécurité, pas le plan)

Ensuite, écrivez un heartbeat comme last_success_at après l'envoi des e-mails. Lancez une vérification séparée toutes les 15 minutes qui alerte si now - last_success_at est supérieur à 24 heures + un intervalle. Cela attrape rapidement le problème "la tâche s'est arrêtée après un déploiement".

Pour un non-technicien, les meilleurs logs et alertes sont en langage simple :

02:00:01 lock_acquired job=nightly-export run_id=abc123
02:07:44 completed job=nightly-export emails_sent=418 last_success_at=2026-01-20T02:07:44Z
02:00:02 skipped job=nightly-export reason=lock_held current_owner=abc123
ALERT: Nightly export has not succeeded in 25h. Last success: 2026-01-19 02:06 UTC. Check scheduler + secrets.

Étapes suivantes si vos jobs programmés restent peu fiables

Si vos jobs chevauchent encore ou « s'arrêtent » après avoir ajouté un verrou et un heartbeat, le planificateur peut être correct mais la logique du job est fragile.

Un signe courant est quand la logique cron vient d'un prototype généré par IA. Vous voyez souvent un verrou qui n'est pas vraiment partagé, des secrets exposés dans les logs, et un comportement de retry qui semble utile mais provoque des effets secondaires en double.

Signes que vous êtes en mode « arrêter de patcher » :

  • Les corrections fonctionnent jusqu'au prochain déploiement, puis les échecs changent de forme
  • Personne ne peut expliquer exactement quand une exécution est considérée comme « terminée »
  • Les retries créent des emails, facturations ou écritures en double
  • L'auth casse de façon imprévisible (tokens expirés, refresh manquant, rôles incorrects)
  • Les logs ne permettent pas de reconstruire une exécution de bout en bout

À ce stade, une courte passe de remédiation vaut souvent mieux que d'autres ajustements. L'objectif n'est pas une réécriture complète, mais de rendre le job prévisible : un point d'entrée clair, une stratégie de verrou unique, un ensemble de timeouts et un endroit unique où le succès est enregistré.

Si vous traitez un codebase IA-généré cassé (surtout depuis des outils comme Lovable, Bolt, v0, Cursor, ou Replit), FixMyMess at fixmymess.ai se concentre sur le diagnostic et la réparation des problèmes comme les exécutions qui se chevauchent, les secrets exposés et la logique de retry fragile pour que le job soit fiable en production.