Rafales d'événements : debounce, unicité ou limitation, que choisir ?
Cet article part d'un exercice, à faire dans le navigateur : Ne traiter que le dernier événement d'une rafale avec #[DebounceFor] →
Un produit modifié trois fois en dix secondes, parce que quelqu'un corrige une faute, puis le prix, puis la photo. Une application qui réindexe le produit dans le moteur de recherche à chaque modification fait trois fois le même travail coûteux. Et si elle s'y prend mal, elle indexe en plus la mauvaise version.
C'est le sujet de l'exercice de Pratique sur #[DebounceFor], un attribut arrivé avec Laravel 13.26. L'exercice part d'un écouteur protégé par ShouldBeUnique, qui laisse passer un bug discret, et le corrige avec le nouvel attribut. Cet article part de là, regarde comment le mécanisme fonctionne, puis compare les différentes façons d'absorber une rafale.
Le bug de départ : unique, mais périmé
L'écouteur de l'exercice ressemble à ce qu'on écrit souvent :
class MettreAJourLIndex implements ShouldQueue, ShouldBeUnique { public int $uniqueFor = 30; public function __construct(private IndexDeRecherche $index) {} public function uniqueId(ProduitModifie $event): string { return (string) $event->produitId; } public function handle(ProduitModifie $event): void { $this->index->indexer($event->produitId, $event->nom); } }
Trois modifications coup sur coup, une seule indexation : le test du nombre passe. Mais ShouldBeUnique pose un verrou au premier envoi et écarte les suivants tant que ce verrou existe, c'est-à-dire jusqu'à la fin du traitement ou au plus uniqueFor secondes. C'est donc le premier événement qui est traité, avec le premier nom. L'index affiche « Clavier » alors que le produit s'appelle « Clavier mécanique AZERTY » depuis vingt secondes, et rien ne viendra le corriger.
ShouldBeUnique répond à la question « ce travail est-il déjà prévu ? ». Ici, la vraie question est « quel est le dernier état à traiter ? ». Ce sont deux besoins différents.
La correction : attendre la fin de la rafale
#[DebounceFor(30)] class MettreAJourLIndex implements ShouldQueue { public function __construct(private IndexDeRecherche $index) {} public function debounceId(ProduitModifie $event): string { return (string) $event->produitId; } public function handle(ProduitModifie $event): void { $this->index->indexer($event->produitId, $event->nom); } }
Le terme vient de l'électronique, où un bouton physique « rebondit » et envoie plusieurs signaux pour une seule pression. On attend que le signal se stabilise avant de le lire. Les développeurs front le connaissent bien, avec le champ de recherche qui n'interroge le serveur qu'une fois la saisie terminée.
Laravel refuse d'ailleurs qu'un écouteur soit à la fois debouncé et ShouldBeUnique : il lève une LogicException. Les deux stratégies sont incompatibles par construction.
Comment ça marche
Le code source est court et vaut la lecture, parce qu'il éclaire les limites du mécanisme.
- À chaque envoi, Laravel écrit dans le cache, sous une clé dérivée de l'écouteur et du
debounceId, un jeton aléatoire : le « propriétaire » actuel. Le job est mis en file avec un délai de 30 secondes, et emporte ce jeton. Attention : si l'écouteur fixe déjà son propre délai (withDelay()ou#[Delay]), c'est ce délai qui l'emporte sur celui deDebounceFor, sans avertissement. - Un nouvel envoi pour le même produit écrit un nouveau jeton dans le cache, qui remplace l'ancien.
- Quand un job arrive à échéance, le worker compare son jeton avec celui du cache. S'ils diffèrent, un envoi plus récent existe : le job est supprimé sans être exécuté, et Laravel émet un événement
JobDebounced. Sinon, le job s'exécute.
Résultat : seul le job du dernier envoi passe, 30 secondes après celui-ci. Ce mécanisme a plusieurs conséquences.
Les jobs sont créés, puis jetés
Le debounce n'économise pas la file d'attente : trois modifications créent toujours trois jobs, et le worker en dépile toujours trois. Ce qu'on économise, c'est le travail : deux des trois jobs s'arrêtent après une lecture dans le cache. Pour une indexation qui coûte un appel réseau à Elasticsearch ou à Meilisearch, c'est l'essentiel. Mais pour un import de 50 000 produits, ce sont toujours 50 000 jobs dans la file.
Le cache devient un point de coordination
Toute la décision repose sur le cache. Sur plusieurs serveurs, il doit être partagé (Redis, Memcached, base de données) : un cache file ou array propre à chaque machine rendrait le mécanisme inopérant. L'écouteur peut désigner un autre magasin de cache que celui par défaut avec une méthode debounceVia().
Et si la clé disparaît du cache (éviction sous pression mémoire, redémarrage de Redis sans persistance), Laravel choisit d'exécuter le job plutôt que de le perdre. C'est le bon choix, mais cela veut dire qu'un traitement en double reste possible. Le debounce réduit les doublons, il ne les interdit pas.
Le cas le plus courant n'est d'ailleurs pas l'éviction : le jeton a une durée de vie de dix fois la fenêtre, et au moins cinq minutes. Si la file accumule plus de retard que cela, la clé expire avant que les jobs ne soient dépilés, et tous les jobs de la rafale s'exécutent. Une file engorgée, justement le jour où il y a beaucoup de modifications, perd donc son debounce.
Le dernier événement est traité, avec le délai en plus
Le travail commence au plus tôt 30 secondes après la dernière modification. Pour un index de recherche, c'est acceptable. Pour une notification « votre commande est expédiée », beaucoup moins. Choisir la fenêtre, c'est arbitrer entre le nombre de traitements évités et la fraîcheur des données.
Le piège du flux continu : maxWait
Imaginez un produit dont le stock est mis à jour toutes les dix secondes par un entrepôt. Avec une fenêtre de 30 secondes, chaque mise à jour repousse la précédente : la rafale ne finit jamais, et le produit n'est jamais réindexé.
C'est le rôle de maxWait :
#[DebounceFor(30, maxWait: 120)]
Laravel note l'heure du premier envoi de la rafale. Si un envoi arrive alors que 120 secondes sont déjà écoulées, il est mis en file sans délai, et ce compteur repart de zéro. Le retard est donc borné à environ deux minutes, à une réserve près : maxWait garantit un envoi sans délai, pas une exécution. Si la file est elle-même en retard et qu'un nouvel envoi arrive avant que le worker n'ait pris ce job, son jeton est remplacé, et il est jeté comme les autres. (Depuis Laravel 13.27, la fenêtre de maxWait repart aussi de zéro quand un job debouncé s'exécute.)
Dès qu'un événement peut arriver en continu, je mets un maxWait. Sans lui, un debounce peut retarder indéfiniment le traitement d'un objet très actif, et c'est souvent l'objet le plus important.
Traiter l'état, pas l'événement
Il reste une faiblesse dans l'écouteur de l'exercice, que l'exercice ne demande pas de corriger : il indexe le nom transporté par l'événement. Tant que le dernier événement traité est bien le plus récent, tout va bien. Mais entre un retry après une erreur, un job en double après une éviction de cache et deux workers qui traitent les files dans un ordre légèrement différent, « le dernier événement » est une notion plus fragile qu'il n'y paraît.
En production, je préfère que l'écouteur relise l'état actuel :
public function handle(ProduitModifie $event): void { $produit = Produit::find($event->produitId); if ($produit === null) { $this->index->retirer($event->produitId); return; } $this->index->indexer($produit->id, $produit->nom); }
L'événement ne dit plus « voici le nouveau nom », mais « ce produit a changé, va voir ». Le traitement devient idempotent : l'exécuter deux fois, ou dans le désordre, donne le même index. Le debounce sert alors uniquement à économiser du travail, et plus à garantir la justesse des données. C'est une répartition des rôles beaucoup plus robuste.
Un corollaire : un événement envoyé dans une transaction de base de données doit partir après le commit (ShouldDispatchAfterCommit sur l'événement, ou after_commit sur la connexion de file). Sinon, le worker peut relire la base avant que la modification soit visible. Avec 30 secondes de délai, c'est peu probable, mais une transaction lente reste possible, et un maxWait atteint fait partir le job sans délai. Quand l'envoi est différé après le commit, Laravel libère aussi le verrou de debounce si la transaction est annulée.
Debounce, unicité, limitation : le tableau
Il existe plusieurs outils voisins pour absorber une rafale, et ils ne répondent pas à la même question :
| Outil | Ce qu'il garde | Quand l'utiliser |
|---|---|---|
#[DebounceFor] |
le dernier envoi, après un moment de calme | synchroniser un état qui change par rafales : index de recherche, cache, export vers un service tiers |
ShouldBeUnique |
le premier envoi ; les suivants sont ignorés tant qu'il n'est pas traité | un travail qui ne dépend pas du contenu de l'événement : « recalculer les statistiques », « régénérer le sitemap » |
ShouldBeUniqueUntilProcessing |
le premier envoi, mais en laissant passer un nouvel envoi dès que le traitement commence | le même, quand un changement survenu pendant le traitement doit aussi être pris en compte |
middleware WithoutOverlapping |
tous les envois, mais jamais deux en même temps pour une même clé | protéger une ressource qui ne supporte pas la concurrence |
middleware RateLimited |
tous les envois, étalés dans le temps | respecter le quota d'une API externe |
Une remarque sur la troisième ligne : si l'écouteur relit l'état en base, comme dans la section précédente, ShouldBeUniqueUntilProcessing devient une alternative crédible au debounce, sans délai artificiel. La première modification déclenche le traitement tout de suite. Les suivantes, si elles arrivent pendant ce traitement, en déclenchent un nouveau qui relira l'état final. Le prix : plus de traitements qu'avec un debounce, mais aucune latence ajoutée.
Quand le debounce par objet ne suffit plus
Le debounceId regroupe les envois d'un même objet. Cent produits modifiés en même temps par un import, c'est cent indexations, même avec un debounce parfait. Or un moteur de recherche indexe beaucoup mieux cent documents d'un coup que cent fois un document.
Pour ce cas, il faut changer de modèle : chaque modification ajoute l'identifiant du produit dans un ensemble (un SADD Redis, une table « à réindexer »), et un seul job, déclenché régulièrement ou lui-même debouncé avec une clé fixe, vide l'ensemble et indexe par lots. On ne debounce plus des événements, on accumule des changements. C'est plus de code, mais c'est ce qui tient la charge d'un import massif.
Tester un debounce
Un dernier point, sur la manière dont l'exercice le teste. Queue::fake() ne servirait à rien ici : il enregistre les envois sans les exécuter, alors que tout le comportement se joue au moment de l'exécution. Les tests de l'exercice utilisent donc une vraie file (le pilote database), avancent l'horloge et lancent un vrai worker :
foreach ($modifications as [$produitId, $nom]) { ProduitModifie::dispatch($produitId, $nom); $this->travel(2)->seconds(); } $this->travel(60)->seconds(); Artisan::call('queue:work', ['--stop-when-empty' => true, '--sleep' => 0]);
Puis ils vérifient ce que le système a produit : une indexation par produit, avec le dernier nom. C'est plus lent qu'un fake, mais c'est le seul moyen de tester ce qui compte vraiment. En production, je conseille aussi d'écouter JobDebounced pour compter les jobs écartés : si ce compteur reste à zéro, le debounce ne sert à rien. S'il est très élevé, la fenêtre est peut-être trop longue pour vos utilisateurs.
Ce que j'en retiens
#[DebounceFor] corrige un vrai défaut d'usage : on utilisait ShouldBeUnique pour absorber des rafales, et on indexait la mauvaise version. Le nouvel attribut fait ce qu'on voulait vraiment, en quelques lignes.
Mais un debounce reste une optimisation, pas une garantie. Il ne réduit pas la file, il dépend du cache, il ajoute de la latence, et il laisse passer des doublons quand le cache flanche. La robustesse vient d'ailleurs : un traitement qui relit l'état et peut s'exécuter deux fois sans dommage. Le debounce rend ensuite ce traitement moins coûteux.
L'exercice se lance dans le navigateur, avec un vrai Laravel 13 et une vraie file d'attente : on y voit le bug de ShouldBeUnique, puis sa correction.