Ce qui se passe
Laravel 13.26 étend l'attribut #[DebounceFor] aux listeners d'événements
mis en file. La contribution est signée @stevebauman.
L'attribut existait déjà depuis Laravel 13.6, mais uniquement pour les jobs. Le code piloté par événements devait donc être restructuré en jobs dispatchés à la main pour en bénéficier — ou passer par un paquet communautaire comme Laravel Debounce.
Le problème
Le scénario est familier. Un import produit touche le même enregistrement
quarante fois en une minute. ProductUpdated part quarante fois. Le listener
qui reconstruit l'index de recherche s'exécute quarante fois, et chaque passage
indexe un état que le suivant écrasera.
La file fait exactement ce qu'on lui a demandé, et 97 % du travail est du gaspillage.
Ce qu'on veut : qu'une rafale d'événements identiques se replie sur un seul appel du handler, à la fin de la rafale, portant l'état le plus récent.
Comment ça marche
On accroche l'attribut à la classe du listener, avec une fenêtre en secondes :
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\DebounceFor;
#[DebounceFor(30, maxWait: 120)]
class UpdateProductSearchIndex implements ShouldQueue
{
public function debounceId(ProductUpdated $event): string
{
return (string) $event->product->getKey();
}
public function handle(ProductUpdated $event): void
{
ProductIndexer::index($event->product->fresh());
}
}Tous les ProductUpdated du produit 42 dans une fenêtre de 30 secondes donnent
un seul appel à handle(), celui du dernier événement de la rafale. Le
produit 43 a sa propre fenêtre, parce que debounceId() la clé par ressource.
Sans debounceId, tous les envois du listener partagent une fenêtre unique —
c'est le comportement voulu pour un listener du genre « reconstruire le plan du
site », qui ne prend aucune entrée par ressource. L'identifiant peut aussi être
une simple propriété $debounceId quand il ne dépend pas de l'événement.
Le mécanisme, dans le détail : chaque envoi met le listener en file avec un délai égal à la fenêtre, et enregistre en cache un jeton de propriété, clé par classe de listener et identifiant de debounce. Un envoi plus récent écrase le jeton. Quand un exemplaire plus ancien finit par s'exécuter, il constate qu'il n'est plus propriétaire du jeton et s'abandonne. Le dernier envoi gagne.
maxWait et la famine
Le debounce pur a un mode de défaillance connu : un flux d'événements qui ne s'interrompt jamais 30 secondes repousse le traitement indéfiniment.
C'est le rôle de maxWait. Avec #[DebounceFor(30, maxWait: 120)], dès que les
envois ont fait glisser la fenêtre pendant 120 secondes, le suivant s'exécute
sans délai au lieu de prolonger encore le report. Un import chargé obtient
donc ses écritures repliées à raison d'environ une indexation toutes les deux
minutes, plutôt que quarante — ou zéro.
Deux réglages interagissent avec le délai : un delay explicite posé sur le
listener a la priorité sur celui dérivé du debounce, et debounceVia() permet
de choisir le magasin de cache qui gère les jetons — utile quand votre cache par
défaut n'est pas partagé par tous les serveurs qui émettent les événements.
Les trois règles à connaître
Pas de ShouldBeUnique. Les deux fonctionnalités posent des verrous
opposés : premier arrivé gagnant contre dernier arrivé gagnant. Les combiner
lève désormais une LogicException au moment de l'envoi, au lieu de désigner un
gagnant en silence.
La portée est le listener, pas l'événement. Les autres listeners de
ProductUpdated s'exécutent pour chaque événement. Seul celui qui porte
l'attribut se replie. Le debounce est une propriété du travail, pas du flux
d'événements.
Le handler voit l'événement déclencheur : relisez l'état. L'objet qui
survit au debounce est le dernier envoyé, mais au moment de l'exécution, même
lui peut être périmé. D'où le $event->product->fresh() de l'exemple. Un
listener debounced doit traiter l'événement comme un pointeur vers une
ressource, pas comme une charge utile.
C'est cette dernière habitude qui rend le debounce sûr : si le listener re-dérive sa sortie depuis la base, replier quarante exécutions en une seule change le coût, pas le résultat.