Planifier des tâches en arrière-plan sans les tracas de cron
Apprenez des patterns pour planifier des jobs en arrière-plan avec des workflows et une table de jobs afin d'envoyer rappels, résumés quotidiens et nettoyages de façon fiable.

Pourquoi cron paraît simple… jusqu’à ce que ça ne le soit plus
Cron est parfait le premier jour : écrire une ligne, choisir une heure, oublier. Pour un serveur et une tâche, ça fonctionne souvent.
Les problèmes apparaissent quand la planification devient un comportement produit réel : rappels, résumés quotidiens, nettoyage ou jobs de synchronisation. La plupart des histoires de « tâche manquée » ne viennent pas d’un cron qui plante. Elles viennent de tout autour : redémarrage du serveur, déploiement qui a écrasé le crontab, une tâche qui a duré plus longtemps que prévu, ou un décalage d'horloge ou de fuseau. Et quand vous exécutez plusieurs instances d'app, vous obtenez l'échec inverse : des doublons, parce que deux machines pensent devoir lancer la même tâche.
Les tests sont un autre point faible. Une ligne cron ne vous donne pas un moyen propre de reproduire « que se passe-t-il à 9h00 demain » dans un test fiable. La planification devient alors des vérifications manuelles, des surprises en production et de la chasse aux logs.
Avant de choisir une approche, soyez clair sur ce que vous planifiez. La plupart des travaux en arrière-plan tombent dans quelques catégories :
- Rappels (envoyer à un moment précis, une seule fois)
- Résumés quotidiens (agréger des données puis envoyer)
- Tâches de nettoyage (supprimer, archiver, expirer)
- Synchronisations périodiques (pull/push de mises à jour)
Parfois, vous pouvez éviter la planification entièrement. Si une action peut se produire au moment d’un événement (inscription d’un utilisateur, paiement réussi, changement de statut), le travail piloté par événement est souvent plus simple et plus fiable que le travail piloté par le temps.
Quand le temps est nécessaire, la fiabilité tient surtout à la visibilité et au contrôle. Vous voulez un endroit pour enregistrer ce qui doit s’exécuter, ce qui s’est exécuté et ce qui a échoué, plus un moyen sûr de retenter sans créer de doublons.
Le pattern de base : scheduler, table de jobs, worker
Une façon simple d’éviter les soucis de cron est de séparer les responsabilités :
- Un scheduler décide ce qui doit s’exécuter et quand.
- Un worker exécute le travail.
Séparer ces rôles aide de deux façons. Vous pouvez changer la planification sans toucher la logique métier, et vous pouvez changer la logique métier sans casser la planification.
Une table de jobs devient la source de vérité. Au lieu de cacher l’état dans un processus serveur ou une ligne cron, chaque unité de travail est une ligne : quoi faire, pour qui, quand exécuter, et ce qui s’est passé la dernière fois. Quand quelque chose tourne mal, vous pouvez l’inspecter, le relancer ou l’annuler sans supposer.
Un flux typique ressemble à ceci :
- Le scheduler scanne les jobs dus (par exemple,
run_at <= nowetstatus = queued). - Il réclame un job pour qu’un seul worker le prenne.
- Un worker lit les détails et effectue l’action.
- Le worker enregistre le résultat dans la même ligne.
L’idée clé est de rendre le travail reprenable, pas magique. Si un worker plante à mi-chemin, la ligne du job doit toujours indiquer ce qui s’est passé et la prochaine action à faire.
Concevoir une table de jobs qui reste utile
Une table de jobs doit répondre rapidement à deux questions : que faut-il exécuter ensuite, et que s’est-il passé la dernière fois.
Commencez par un petit ensemble de champs qui couvrent identité, timing et progression :
- id, type : un identifiant unique plus un type court comme
send_reminderoudaily_summary. - payload : JSON validé avec uniquement ce dont le worker a besoin (par exemple
user_id, pas l’objet utilisateur complet). - run_at : quand le job devient éligible.
- status :
queued,running,succeeded,failed,canceled. - attempts : incrémenté à chaque essai.
Ajoutez ensuite quelques colonnes opérationnelles qui rendent la concurrence sûre et les incidents plus faciles à gérer. locked_at, locked_by et locked_until permettent à un worker de réclamer un job pour ne pas l’exécuter deux fois. last_error doit être un message court (et éventuellement un code d’erreur), pas un dump de stack trace qui alourdit les lignes.
Enfin, conservez des timestamps utiles pour le support et les rapports : created_at, updated_at, et finished_at. Ils vous permettent de répondre à des questions comme « Combien de rappels ont échoué aujourd’hui ? » sans fouiller les logs.
Les index comptent parce que votre système demande constamment « quelle est la prochaine tâche ? ». Deux index qui paient souvent :
(status, run_at)pour récupérer vite les jobs dus(type, status)pour inspecter ou mettre en pause une famille de jobs en cas de problème
Pour les payloads, préférez un JSON petit et ciblé et validez-le avant insertion. Stockez des identifiants et des paramètres, pas des instantanés de données métier. Traitez la forme du payload comme un contrat d’API afin que les anciens jobs en file puissent encore s’exécuter après des changements de l’app.
Cycle de vie d’un job : statuts, verrouillage et idempotence
Un job runner reste fiable quand chaque job suit un cycle de vie petit et prévisible. Ce cycle est votre filet de sécurité quand deux workers commencent en même temps, qu’un serveur redémarre en cours d’exécution, ou que vous devez retenter sans créer de doublons.
Une machine d’état simple suffit généralement :
- queued : prêt à exécuter à ou après
run_at - running : réclamé par un worker
- succeeded : terminé et ne doit plus s’exécuter
- failed : terminé avec une erreur et nécessite une attention
- canceled : arrêté intentionnellement (par exemple, l’utilisateur s’est désinscrit)
Réclamer un job sans double exécution
Pour éviter les doublons, la réclamation d’un job doit être atomique. L’approche courante est un verrou avec timeout (un bail) : un worker réclame un job en mettant status=running et en écrivant locked_by et locked_until. Si le worker plante, le bail expire et un autre worker peut reprendre le job.
Un ensemble de règles pratiques pour réclamer :
- réclamer seulement les jobs
queueddontrun_at \u003c= now - définir
status,locked_byetlocked_untildans la même mise à jour - reprendre les jobs
runningseulement quandlocked_until \u003c now - garder le bail court et l’étendre si le job est long
Idempotence (l’habitude qui vous sauve)
Idempotence signifie : si le même job s’exécute deux fois, le résultat reste correct.
L’outil le plus simple est une clé unique. Par exemple, pour un résumé quotidien vous pouvez garantir un job par utilisateur et par jour avec une clé comme summary:user123:2026-01-25. Si une insertion en double se produit, elle pointe vers le même job au lieu d’en créer un second.
Ne marquez le succès que lorsque l’effet de bord est réellement terminé (email envoyé, enregistrement mis à jour). Si vous réessayez, le chemin de réessai ne doit pas créer un deuxième email ou une écriture dupliquée.
Réessais et gestion des échecs sans drame
Les réessais sont l’endroit où les systèmes de jobs deviennent soit fiables soit bruyants. L’objectif est simple : retenter quand l’échec est probablement temporaire, arrêter quand il ne l’est pas.
Une politique de réessai par défaut comprend généralement :
- un nombre max d’attempts (par exemple, 5 essais au total)
- une stratégie de délai (délai fixe ou backoff exponentiel)
- conditions d’arrêt (ne pas réessayer les erreurs de type “entrée invalide”)
- jitter (un petit décalage aléatoire pour éviter des pics de réessai)
Au lieu d’inventer un nouveau statut pour les réessais, vous pouvez souvent réutiliser queued : définissez run_at à l’heure du prochain essai et remettez le job dans la file. Ça garde la machine d’état petite.
Quand un job peut faire des progrès partiels, considérez cela comme normal. Stockez un checkpoint pour qu’un réessai puisse continuer en toute sécurité, soit dans le payload du job (comme last_processed_id), soit dans une table liée.
Exemple : un job de résumé quotidien génère des messages pour 500 utilisateurs. S’il échoue à l’utilisateur 320, stockez l’ID du dernier utilisateur traité avec succès et réessayez à partir de 321. Si vous stockez aussi un enregistrement summary_sent par utilisateur et par jour, un rerun peut ignorer les utilisateurs déjà faits.
Logs qui aident vraiment
Loggez ce qu’il faut pour déboguer en quelques minutes :
- id du job, type et numéro d’attempt
- entrées clés (user/team id, plage de dates)
- timings (started_at, finished_at, next run time)
- résumé d’erreur court (plus stack trace si vous enregistrez cela ailleurs)
- compte des effets (emails envoyés, lignes mises à jour)
Pas à pas : construire une boucle de scheduler simple
Une boucle de scheduler est un petit processus qui se réveille à un rythme fixe, cherche le travail dû et le confie. L’objectif est une fiabilité ennuyeuse, pas un timing parfait. Pour beaucoup d’apps, « se réveiller chaque minute » suffit.
Choisissez la fréquence en fonction de la sensibilité temporelle des jobs et de la charge que votre base peut supporter. Si les rappels doivent être quasi-immediate, tournez toutes les 30 à 60 secondes. Si les résumés quotidiens peuvent dériver un peu, toutes les 5 minutes conviennent et coûtent moins cher.
Une boucle simple :
- Se réveiller et prendre l’heure courante (utilisez UTC).
- Sélectionner les jobs dus où
status = 'queued'etrun_at \u003c= now. - Réclamer les jobs en sécurité pour qu’un seul worker les prenne.
- Confier chaque job réclamé à un worker.
- Dormir jusqu’au tick suivant.
L’étape de réclamation est là où beaucoup de systèmes se cassent. Vous voulez marquer un job comme running (et stocker locked_by et locked_until) dans la même transaction que sa sélection. Beaucoup de bases supportent les lectures « skip locked » pour que plusieurs schedulers puissent tourner sans se piétiner.
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at \u003c= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
Gardez la taille des lots petite (par exemple 50 à 200). Des lots plus grands peuvent ralentir la base et rendre les crashs plus pénibles.
Si le scheduler plante en plein lot, le bail vous sauve. Les jobs bloqués en running redeviennent éligibles après locked_until. Votre worker doit être idempotent pour qu’un job repris ne crée pas d’emails en double ou de doubles facturations.
Patterns pour rappels, résumés quotidiens et nettoyage
La plupart des équipes traitent les mêmes trois types de travail : messages à envoyer à l’heure, rapports périodiques et nettoyage pour garder le stockage performant. La même table de jobs et la même boucle worker peuvent gérer tout ça.
Rappels
Pour les rappels, stockez tout ce qu’il faut pour envoyer le message dans la ligne du job : destinataire, canal (email, SMS, Telegram, in-app), template et l’heure exacte d’envoi. Le worker doit pouvoir exécuter le job sans « aller voir ailleurs » pour le contexte.
Si beaucoup de rappels sont dus en même temps, ajoutez du rate limiting. Plafonnez les messages par minute et par canal et laissez les jobs en trop attendre la prochaine exécution.
Résumés quotidiens
Les résumés quotidiens échouent quand la fenêtre temporelle est floue. Choisissez une heure de coupure stable (par exemple 08:00 dans le fuseau de l’utilisateur) et définissez la fenêtre clairement (par exemple « d’hier 08:00 à aujourd’hui 08:00 »). Stockez la coupure et le fuseau de l’utilisateur avec le job pour que les reruns produisent le même résultat.
Gardez chaque job de résumé petit. S’il doit traiter des milliers d’enregistrements, découpez-le en morceaux (par équipe, par compte, ou par plage d’ID) et enfilez des jobs de suite.
Nettoyage
Le nettoyage est plus sûr quand vous séparez « supprimer » et « archiver ». Décidez ce qui peut être supprimé définitivement (tokens temporaires, sessions expirées) et ce qui doit être archivé (logs d’audit, factures). Exécutez le nettoyage par lots prévisibles pour éviter verrous longs et pics de charge.
Temps et fuseaux horaires : la source cachée de bugs
Beaucoup de pannes viennent de bugs temporels : un rappel part une heure trop tôt, un résumé saute le lundi, ou le nettoyage s’exécute deux fois.
Une bonne valeur par défaut est de stocker les timestamps de planification en UTC et de stocker séparément le fuseau de l’utilisateur. Votre run_at doit être un moment UTC. Quand un utilisateur demande « 9:00 AM mon heure », convertissez en UTC au moment de la planification.
Les changements d’heure (DST) sont là où les configurations naïves se cassent. « Tous les jours à 9:00 » n’est pas la même chose que « toutes les 24 heures ». Lors des bascules DST, 9:00 locale correspond à un UTC différent, et certaines heures locales n’existent pas (avancer l’heure) ou surgissent deux fois (reculer). L’approche sûre est de recalculer la prochaine occurrence locale à chaque reprogrammation, puis de la convertir en UTC.
Pour un résumé quotidien, décidez ce que « une journée » signifie avant d’écrire du code. Une journée calendaire (minuit à minuit dans le fuseau utilisateur) correspond aux attentes humaines. « Les dernières 24 heures » est plus simple mais dérive et surprend les gens.
Les données tardives sont inévitables : un événement arrive après un réessai, ou une note est ajoutée quelques minutes après minuit. Décidez si les événements tardifs appartiennent à « hier » (avec une période de grâce) ou à « aujourd’hui », et gardez cette règle cohérente.
Un tampon pratique peut éviter les manqués :
- scanner les jobs dus jusqu’à 2 à 5 minutes en arrière
- rendre les jobs idempotents pour que les reruns soient sûrs
- enregistrer la plage temporelle couverte dans le payload pour que les résumés restent cohérents
Erreurs courantes qui causent des exécutions manquées ou en double
La plupart des douleurs viennent de quelques hypothèses prévisibles.
La plus grande est de supposer une exécution « exactement une fois ». Dans les systèmes réels, les workers redémarrent, les appels réseau timeout, et les verrous peuvent être perdus. Vous obtenez généralement une livraison « au moins une fois », ce qui signifie que les doublons sont normaux et votre code doit les tolérer.
Autre erreur : effectuer les effets avant la vérification de déduplication (envoyer l’email, facturer la carte) sans garde. Un simple garde résout souvent le problème : un timestamp sent_at, une clé unique comme (user_id, reminder_type, date) ou un token de déduplication stocké.
La visibilité est le défaut suivant. Si vous ne pouvez pas répondre à « qu’est-ce qui est bloqué, depuis quand et pourquoi ? », vous finirez par deviner. Les données minimales à garder proches sont : statut, nombre d’attempts, prochaine heure planifiée, dernière erreur et id du worker.
Les erreurs qui apparaissent le plus souvent :
- concevoir les jobs comme s’ils s’exécutaient exactement une fois, puis être surpris par les doublons
- écrire des effets de bord sans vérification de déduplication
- exécuter un énorme job qui tente de tout faire et qui timeoute en cours de route
- réessayer indéfiniment sans plafond
- ne pas avoir de visibilité basique sur la file (pas de vue claire du backlog, des échecs, des items long-running)
Un exemple concret : un job de résumé quotidien boucle sur 50 000 utilisateurs et timeoute à l’utilisateur 20 000. Au réessai, il repart du début et renvoie encore les résumés aux 20 000 premiers à moins que vous suiviez la progression par utilisateur ou que vous le scindiez en jobs par utilisateur.
Checklist rapide pour un système de jobs fiable
Un job runner n’est « terminé » que lorsque vous pouvez lui faire confiance à 2h du matin.
Assurez-vous d’avoir :
- Visibilité de la file : comptes pour queued vs running vs failed, plus le job le plus ancien en file.
- Idempotence par défaut : supposez que chaque job peut s’exécuter deux fois ; utilisez des clés uniques ou des marqueurs “déjà traité”.
- Politique de réessai par type de job : réessais, backoff et condition d’arrêt claire.
- Stockage temporel cohérent : conservez
run_aten UTC ; convertissez uniquement à la saisie et à l’affichage. - Verrous récupérables : un bail pour que les crashs ne laissent pas des jobs en cours indéfiniment.
Limitez aussi la taille des lots (combien de jobs vous réclamez en une fois) et la concurrence des workers (combien s’exécutent en même temps). Sans limites, un pic peut surcharger la BDD ou affamer d’autres travaux.
Un exemple réaliste : rappels et résumés pour une petite équipe
Un petit outil SaaS a 30 comptes clients. Chaque compte veut deux choses : un rappel à 9h pour les tâches ouvertes, et un résumé à 18h des changements de la journée. Ils ont aussi besoin d’un nettoyage hebdomadaire pour que la BDD ne se remplisse pas de vieux logs et tokens expirés.
Ils utilisent une table de jobs plus un worker qui poll les jobs dus. Lorsqu’un nouveau client s’inscrit, le backend planifie la première exécution des rappels et des résumés selon le fuseau du client.
Les jobs sont créés à quelques moments communs : à l’inscription (créer des schedules récurrents), sur certains événements (enfiler des notifications ponctuelles), au tick du scheduler (insérer les runs à venir), et les jours de maintenance (enfiler le nettoyage).
Un mardi, le fournisseur d’email a une panne temporaire à 8:59. Le worker essaye d’envoyer les rappels, obtient un timeout et reprogramme ces jobs en appliquant un backoff (par exemple 2 minutes, puis 10, puis 30), en incrémentant attempts à chaque fois. Comme chaque job de rappel a une clé d’idempotence comme account_id + date + job_type, les réessais ne produisent pas de doublons si le fournisseur revient en vol.
Le nettoyage tourne chaque semaine par petits lots, donc il n’empêche pas les autres travaux. Plutôt que de supprimer un million de lignes en un job, il supprime jusqu’à N lignes par exécution et se reprogramme jusqu’à ce que ce soit fini.
Quand un client se plaint « Je n’ai jamais reçu mon résumé », l’équipe vérifie la table de jobs pour ce compte et ce jour : statut du job, nombre d’attempts, champs de lock courants et la dernière erreur retournée par le fournisseur. Ça transforme « ça aurait dû partir » en « voici exactement ce qui s’est passé ».
Prochaines étapes : implémenter, observer, puis scaler
Choisissez un type de job et construisez-le bout à bout avant d’en ajouter d’autres. Un job de rappel unique est un bon point de départ parce qu’il touche à tout : planification, réclamation, envoi d’un message et enregistrement du résultat.
Commencez par une version dont vous pouvez avoir confiance :
- créez la table de jobs et un worker qui traite un type de job
- ajoutez une boucle de scheduler qui réclame et exécute les jobs dus
- stockez assez de payload pour exécuter le job sans supposition
- loggez chaque tentative et résultat pour que “Est-ce que ça a tourné ?” soit une question de 10 secondes
- ajoutez un chemin de relance manuel pour les jobs échoués afin que la récupération n’exige pas un déploiement
Une fois en fonctionnement, rendez-le observable pour des humains. Même une vue admin basique paye vite : rechercher des jobs par statut, filtrer par période, inspecter le payload, annuler un job bloqué, relancer un id de job spécifique.
Si vous préférez modéliser ce type de scheduler et ce flux worker avec une logique backend visuelle, AppMaster (appmaster.io) peut modéliser la table de jobs dans PostgreSQL et implémenter la boucle claim-process-update comme un Business Process, tout en générant du code source réel pour le déploiement.


