Durable Delays : gérer le temps comme un état distribué
SchemaBridge Team · 2026-01-10 · Time, Scheduling, Orchestration
Gérer des attentes de 30 jours sans fuite mémoire. Pourquoi `sleep()` est un anti-pattern des systèmes distribués.
Le mensonge de sleep() : pourquoi le temps est la primitive distribuée la plus difficile
Tous les langages de programmation disposent d'une commande pour attendre. En Python, c'est time.sleep(). En Node.js, c'est setTimeout(). En Java, c'est Thread.sleep(). Ces commandes sont simples, intuitives et, pour tout ce qui dépasse quelques secondes, totalement inutiles dans un système distribué en production.
L'humble sleep() est un mensonge parce qu'il suppose un environnement stable. Il suppose que la machine qui exécute le code restera en vie, que le processus ne sera pas tué par un load balancer et que la mémoire ne sera pas récupérée par l'OS. Dans un environnement cloud moderne, ces hypothèses sont fausses. Si vous faites sleep(24 60 60) (une journée) dans un pod Kubernetes standard, ce pod sera très probablement remplacé, replanifié ou redéployé avant la fin de la journée.
Quand le processus meurt, votre « sleep » meurt avec lui. Votre logique métier se perd dans le vide. C'est ainsi que naît le « logiciel amnésique » : des systèmes qui perdent la trace des périodes d'essai des clients, ratent des renouvellements d'abonnement et n'envoient pas des e-mails de relance critiques.
La hiérarchie du temps : des secondes aux mois
Pour gérer le temps correctement, il faut d'abord le catégoriser. Tous les délais ne se valent pas :
1. Délais transitoires (millisecondes à secondes) : Il s'agit généralement de backoffs réseau ou d'attentes sur un verrou de base de données rapide. sleep() est parfois acceptable ici, car le risque de crash dans une fenêtre de 50 ms est faible.
2. Délais courts (minutes) : C'est là que sleep() commence à échouer. Vous immobilisez un thread de worker ou les ressources d'un conteneur pendant des minutes sans rien faire. C'est du gaspillage et un risque pour le pool de connexions.
3. Durable delays (heures à mois) : C'est le domaine des cycles de vie métier. Un essai gratuit de 14 jours, un délai de paiement de 30 jours ou un calendrier de maintenance semestriel. Ils ne peuvent pas vivre dans le code ; ils doivent vivre dans l'infrastructure.
L'architecture des workflows endormis
Chez SchemaBridge, nous traitons le temps comme un état durable. Lorsque votre workflow atteint un vertex DELAY, il ne bloque aucun thread. L'étape est mise en attente avec le statut SUSPENDED, l'intention de réveil est écrite en base de données, et le worker est immédiatement libre pour d'autres tâches.
Le modèle de polling en base (faible précision, haute durabilité)
L'approche artisanale classique stocke l'« heure de réveil » dans une table, et un worker en arrière-plan (le « poller ») interroge la table toutes les quelques secondes : SELECT * FROM timers WHERE wake_up < NOW(). C'est durable, car un enregistrement en base survit aux redémarrages — mais vous le payez par un flux constant de requêtes vides et un plancher de précision égal à votre intervalle de polling.
L'approche SchemaBridge
SchemaBridge conserve la durabilité de l'enregistrement en base et abandonne le polling à intervalle fixe. Chaque réveil est persisté sous la forme d'une ligne avec un execute_at absolu dans une table PostgreSQL scheduled_tasks (derrière notre ScheduledTaskRepository). Le clock worker dort jusqu'au plus proche execute_at en attente, et il est réveillé plus tôt par un push PostgreSQL LISTEN/NOTIFY chaque fois qu'une tâche devient exécutable. Quand des tâches sont dues, il les réclame en une seule instruction avec FOR UPDATE SKIP LOCKED, de sorte que plusieurs réplicas du clock worker peuvent se répartir le backlog sans entrer en collision. Un plafond d'inactivité large n'existe qu'en filet de sécurité, au cas où une notification serait manquée.
-- Forme simplifiée du claim : lignes échues, les plus anciennes d'abord, réparties entre réplicas
UPDATE scheduled_tasks SET status = 'PROCESSING'
WHERE task_id IN (
SELECT task_id FROM scheduled_tasks
WHERE status = 'PENDING' AND execute_at <= :now
ORDER BY execute_at
FOR UPDATE SKIP LOCKED
LIMIT 100
)
RETURNING task_id;
Annulation et sorties anticipées : changer le futur
Un durable delay ne raconte que la moitié de l'histoire. Si un utilisateur se trouve dans une étape « Attendre 3 jours le paiement » et qu'il paie au bout de 2 heures, que doit-il se passer ?
D'abord, l'annulation. Arrêter un run dans SchemaBridge annule l'exécution, puis désarme les timers de délai et d'approbation en attente de ce run, de sorte qu'un parcours arrêté ne se réveille pas quelques jours plus tard pour continuer. L'ordre est délibéré — annuler d'abord, désarmer ensuite — si bien que le pire cas est un timer qui se déclenche sur un run déjà annulé, où les gardes d'état terminal en font un no-op inoffensif.
Ensuite, le réveil anticipé. SchemaBridge ne dispose pas d'une primitive intégrée « réveiller ce délai à l'arrivée d'un événement externe ». Un vertex DELAY attend la durée configurée. Il existe deux façons honnêtes de modéliser une sortie anticipée :
- Revérifier au réveil : À la fin du délai, une étape Gateway interroge l'état actuel (par ex. « cette facture a-t-elle été payée ? »), et une arête conditionnelle oriente vers le chemin « déjà fait » ou vers le chemin de relance. Le délai n'a jamais besoin d'être interrompu ; la décision est prise sur des données fraîches.
- Scinder au niveau de l'événement : Modélisez l'événement lui-même (le webhook de paiement) comme son propre point d'entrée ou workflow, et arrêtez le run en attente lorsqu'il arrive.
Aucun de ces deux patterns n'oblige le moteur à faire courir un timer contre un signal, ce qui est précisément le genre de course que l'on veut éliminer par conception plutôt que de s'y fier.
Dérive d'horloge et précision
Dans tout système distribué, les horloges ne sont jamais parfaitement synchronisées. C'est le phénomène de dérive d'horloge (clock drift). Si l'horloge d'une machine avance de 50 ms sur celle d'une autre, un « Attendre 1 seconde » peut se comporter différemment selon l'endroit où la tâche est prise en charge.
SchemaBridge limite le problème en faisant du réveil un horodatage absolu stocké en un seul endroit : la colonne execute_at dans PostgreSQL. L'armement d'un délai calcule now + delaySeconds une seule fois et le stocke ; dès lors, aucun worker ne tient de compte à rebours en mémoire, il n'y a donc pas de timer par worker susceptible de dériver ou d'être perdu au redémarrage. Le décalage d'horloge ordinaire entre machines existe toujours, mais pour des délais métier mesurés en heures ou en jours, il est sans incidence sur le résultat.
Scénario illustratif : automatiser une campagne drip de 30 jours
Prenons une plateforme de marketing automation aux prises avec la logique de son « Welcome Journey ».
Le défi
Un parcours comprend :
- Envoyer l'e-mail 1 (jour 1).
- Attendre 3 jours.
- Si l'utilisateur n'a pas cliqué, envoyer l'e-mail 2.
- Attendre 7 jours.
- Si l'utilisateur n'est pas passé à l'offre supérieure, envoyer un code promo.
Bâtis sur une solution maison faite de cron jobs et d'une table Postgres, de tels parcours sont fragiles : pendant une fenêtre de maintenance de la base de données ou un déploiement, une partie des utilisateurs peut rester « bloquée » dans un état d'attente et ne jamais recevoir l'e-mail suivant.
La méthode SchemaBridge
Imaginez les mêmes parcours reconstruits sous forme de workflows SchemaBridge.
1. Délais visuels : Vous glissez un vertex DELAY dans le graphe et réglez delaySeconds sur 259200 (3 jours) et 604800 (7 jours). L'éditeur les affiche sous forme de durées compactes comme « 3d ».
2. Reprise durable : Pendant les déploiements, les runs en attente restent simplement stationnés en base de données. Quand le clock worker revient, il réclame les timers arrivés à échéance pendant l'interruption, les plus anciens d'abord, et les reprend.
3. Revérifier au réveil : Après chaque délai, une étape Gateway demande au fournisseur d'e-mail ou de facturation si l'utilisateur a cliqué ou est passé à l'offre supérieure, et une arête conditionnelle choisit l'étape suivante.
Le bénéfice attendu
- Fiabilité : Un parcours en pause survit aux redémarrages et aux déploiements, si bien que la classe de bugs « bloqué dans un état d'attente » devrait largement disparaître.
- Plaisir des développeurs : La logique cron maison et le service « Job Poller » ne sont plus nécessaires.
- Une réserve à intégrer dans la conception : une étape qui reprend après un long délai et lit
steps.<alias>.outputd'une étape antérieure dépend du fait que cette sortie se trouve encore dans la fenêtre de rétention de votre offre. Pour les parcours qui s'étendent sur plusieurs mois, transportez dans le payload les valeurs dont vous avez besoin plutôt que d'aller les rechercher en arrière.
Comparaison : stratégies de planification existantes
| Fonctionnalité | setTimeout() | Cron jobs / Quartz | Durable Delays SchemaBridge |
| :--- | :--- | :--- | :--- |
| Persistance | Aucune (volatile) | Manuelle (adossée à une base) | Native (PostgreSQL scheduled_tasks) |
| Scalabilité | Faible (liée à la RAM) | Moyenne (goulot d'étranglement en base) | Élevée (SKIP LOCKED entre réplicas) |
| Annulation | Complexe (handle manuel) | Nettoyage manuel en base | Arrêter un run désarme ses timers |
| Visibilité | Aucune | Requêtes SQL | Étapes en attente (suspendues) visibles dans la vue de l'exécution |
| Résolution | Millisecondes | Secondes/minutes | Réveil par push (LISTEN/NOTIFY), sans polling fixe |
Checklist d'expert pour la logique métier de longue durée
Si vous concevez un système qui attend plus de 5 minutes, suivez ces heuristiques :
1. Libérez le thread : Ne bloquez jamais un worker pendant l'attente. Votre worker doit être sans état et prêt à être tué à tout moment.
2. Externalisez l'horloge : Utilisez un stockage central et durable pour conserver l'heure de réveil, et non le timer en mémoire de la machine locale.
3. Prévoyez les issues anticipées : Partez toujours du principe que l'utilisateur peut effectuer l'action que vous attendez avant l'expiration du délai. Revérifiez l'état à la fin de l'attente au lieu de supposer que rien n'a changé.
4. Auditez la salle d'attente : Surveillez le nombre d'exécutions dont des étapes attendent un minuteur. C'est un indicateur important de la santé de l'activité.
5. Préférez les délais relatifs : Exprimez une attente comme une durée à partir de l'étape précédente (delaySeconds) plutôt que comme une date calendaire fixe, afin que l'écart relatif entre les étapes reste constant si une étape antérieure prend du retard.
Conclusion : le temps est un problème d'infrastructure
Un logiciel qui oublie est un logiciel qui échoue. En traitant le temps comme un état durable de premier plan, nous comblons le fossé entre les événements temps réel et le comportement humain à forte latence. Avec SchemaBridge, un parcours qui s'étend sur des jours ou des semaines survit aux redémarrages et aux déploiements qui tueraient un thread endormi — à condition de prévoir les sorties anticipées et de transporter les données dont les étapes ultérieures ont besoin.
La capacité à maîtriser de manière fiable le timing de vos interactions est un véritable avantage concurrentiel. Nous fournissons l'horloge durable ; vous fournissez le parcours.
Fait partie de notre série Building the Bridge. Voir aussi : Le fossé de l'observabilité pour déboguer des runs longs et multi-étapes sans vous perdre dans les logs.