veilletech.fr
22 août Feed du jour
#04 LARAVEL Article

Laravel : 40 événements, un seul traitement

Traiter l'événement comme un pointeur vers une ressource, jamais comme une charge utile.

Laravel 13.26 étend l'attribut #[DebounceFor] aux listeners d'événements mis en file. Une rafale d'événements identiques se replie sur un seul appel du handler, avec l'état le plus récent, et debounceId permet une fenêtre par ressource. maxWait évite qu'un flux continu ne repousse le traitement indéfiniment.

4 min de lecturevidéo 1:22
Partager
Sommaire6 sections
  1. Ce qui se passe
  2. Le problème
  3. Comment ça marche
  4. maxWait et la famine
  5. Les trois règles à connaître
  6. À retenir

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 :

PHP
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.