Si tu cherches pourquoi Laravel queue:work peut rester vivant tout en ne traitant plus aucun job, tu es au bon endroit. Le scénario est vicieux parce qu’il ressemble à de la “stabilité” : le process est UP, le pod est Ready, parfois même Horizon ne crie pas… mais ta file grossit et ton produit commence à prendre du retard partout.
Le cœur du problème est simple à dire et pénible à diagnostiquer : si la récupération du prochain job (getNextJob) jette une exception “hors base de données” (typiquement côté SQS, Redis, réseau), le worker peut tomber dans une boucle infinie silencieuse (catch → sleep → retry) sans jamais sortir, sans jamais progresser, et sans que tes flags habituels te sauvent.
Le symptôme qui rend fou : tout est UP, rien ne sort
En prod, ça donne une scène très classique. Les jobs s’empilent. Les emails partent plus. Les webhooks prennent 30 minutes. Tes “async” redeviennent du “jamais”. Et pourtant, quand tu regardes tes workers, ils sont là. Ils ne crashent pas. Ils ne redémarrent pas en boucle. Ils consomment peu de CPU, parfois quasi rien. Tu as juste… une absence de débit.
Le piège, c’est que beaucoup de stacks de supervision surveillent la vie du process, pas la progression. Un container qui tourne, c’est facile. Un worker qui traite effectivement des jobs, c’est autre chose. Et si tu n’as pas une métrique “jobs traités/min”, tu peux perdre une demi-journée à regarder des graphiques “tout va bien”.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Pourquoi queue:work peut boucler sans rien traiter
Le worker Laravel, en mode daemon, fait grosso modo toujours la même chose : il essaie de récupérer le prochain job sur la connexion, puis il l’exécute. Si la récupération du job échoue, il gère l’erreur… et réessaie après un sleep.
Le problème, signalé récemment côté framework, c’est qu’il existe un chemin où certaines exceptions (notamment non liées à la DB) peuvent être attrapées d’une manière qui ne déclenche pas les garde-fous que tu imagines. Concrètement, tu te retrouves avec un worker qui ne meurt pas, ne dépasse pas son timeout de job (puisqu’il n’exécute pas de job), et qui réessaie indéfiniment de “pull” un job sans jamais en obtenir.
Avec SQS, ça peut se traduire par un souci transitoire AWS, un problème de credentials/STS, un throttling, une erreur réseau, un DNS foireux dans le cluster, une librairie qui remonte une exception “différente” de ce que Laravel considère comme “fatal”. Avec Redis, c’est souvent un timeout, une reconnexion mal gérée, une saturation, ou une instance qui fait des pauses. Peu importe la cause initiale : ce qui te flingue, c’est le loop sans sortie.
Pourquoi tes logs disent “rien” (et comment arrêter de te mentir)
Sur ces incidents-là, le silence est une fonctionnalité involontaire. Selon ton niveau de logs et ton handler d’exceptions, tu peux n’avoir que des traces sporadiques, ou des messages noyés, ou carrément rien si l’exception est attrapée et que ton niveau de log ne la sort pas. Et même si tu vois une exception passer, tu ne vois pas forcément qu’elle se répète en boucle et que le worker ne progresse plus.
Ce que je cherche en premier, c’est une preuve de non-progression. Pas “le backlog augmente” (ça, on le sait). Je veux quelque chose du style : 0 job traité en X minutes alors que la queue n’est pas vide. Si tu as Horizon, regarde le throughput par queue et l’âge des jobs. Sans Horizon, tu peux déjà exploiter des métriques provider (SQS ApproximateNumberOfMessagesVisible / AgeOfOldestMessage) ou Redis (LLEN, latence, erreurs de connexion) et les croiser avec les logs applicatifs.
Un indicateur très concret : “le process est vivant” mais “le compteur de jobs traités n’a pas bougé”. Si tu n’as pas ce compteur, tu es aveugle. Et c’est exactement pour ça que ce bug coûte cher.
Le détail qui surprend : --timeout, --memory, --max-time ne t’aident pas
Intuitivement, tu te dis : “j’ai mis --max-time, au pire ça redémarre” ou “j’ai --timeout, donc ça ne peut pas rester bloqué”. Sauf que ces options protègent surtout l’exécution des jobs (ou la durée globale, dans certains cas), pas un worker qui tourne gentiment en boucle avant même d’avoir un job en main.
Le worker n’est pas “bloqué” au sens CPU ou deadlock. Il est “bloqué” au sens produit : il fait une action qui échoue, il dort, il recommence. C’est une boucle active, pas un freeze. Donc tu peux avoir un container stable pendant des heures, avec une queue qui explose, et aucune limite ne se déclenche parce que rien ne ressemble à un job long, ni à une fuite mémoire.
Comment le prouver vite en prod : débit + backlog + âge du plus vieux
Quand je dois trancher rapidement, je fais un triangle très simple : backlog, âge, débit. Le backlog te dit “ça s’accumule”. L’âge du plus vieux message te dit “depuis combien de temps c’est cassé”. Et le débit te dit “est-ce qu’on traite encore quelque chose”. Si tu as backlog > 0, âge qui monte, débit à 0, tu peux arrêter de débattre : la queue est morte, même si tes pods respirent.
Le truc qui aide vraiment, c’est de segmenter par queue et par type de job. Sinon tu peux avoir une queue “email” à 0 et une queue “webhooks” en enfer, et tu te fais balader par une moyenne globale qui masque tout.
Reproduire l’incident sans tout casser (et comprendre le chemin)
Si tu veux comprendre plutôt que subir, le plus propre est de simuler une exception dans la phase “fetch next job”. En pratique, ça dépend de ton driver.
Avec SQS, tu peux provoquer des erreurs en jouant sur les credentials, une policy IAM, ou en forçant un endpoint/réseau indisponible dans un environnement de staging. Avec Redis, tu peux couper le réseau vers Redis, mettre un timeout ridicule, ou redémarrer l’instance au mauvais moment. L’objectif n’est pas de faire un chaos test parfait, c’est d’observer ce que fait ton worker quand il n’arrive plus à pull. Est-ce qu’il meurt ? Est-ce qu’il log ? Est-ce qu’il repart ? Ou est-ce qu’il boucle sans fin ?
Fais-le avec une queue dédiée, des jobs sans impact, et des métriques visibles. Le signal que tu cherches, c’est “exceptions de fetch” + “0 job processed” + “process toujours UP”.
Le garde-fou qui change tout : superviser la progression, pas le process
Si je devais résumer la leçon en une phrase : un worker est healthy quand il progresse. Pas quand il existe. Du coup, ce que je mets en place, c’est un heartbeat basé sur des événements réels de traitement.
En Laravel, tu peux t’accrocher aux événements de queue (job processed / job failed) et écrire un timestamp dans Redis. Ça te donne un indicateur bête et robuste : “la dernière fois qu’on a effectivement traité quelque chose”. Ensuite, ton orchestrateur (Kubernetes, ECS, systemd, Supervisor) peut redémarrer le worker si ce timestamp devient trop vieux alors que la queue est censée tourner.
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\ServiceProvider;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event;
class QueueHeartbeatServiceProvider extends ServiceProvider
{
public function boot(): void
{
$touch = function (string $connection, ?string $queue): void {
$key = 'queue:heartbeat:' . $connection . ':' . ($queue ?: 'default');
Redis::setex($key, 300, (string) time());
};
Event::listen(JobProcessed::class, function (JobProcessed $event) use ($touch) {
$touch($event->connectionName, $event->job->getQueue());
});
Event::listen(JobFailed::class, function (JobFailed $event) use ($touch) {
// Un job failed, c'est quand même une preuve de progression.
$touch($event->connectionName, $event->job->getQueue());
});
}
}Ce heartbeat ne remplace pas l’observabilité, mais il te donne un levier opérationnel : une sonde, un watchdog, une alerte. Et ça, contre un loop silencieux, c’est exactement ce qu’il faut.
Alerting utile : “lag” + “no throughput”, pas juste “queue length”
Je suis plutôt dur avec les alertes “taille de queue > X”. Ça alerte trop tard, et ça alerte sur des pics normaux (imports, campagnes email, batch). Ce que tu veux, c’est une alerte qui colle à l’incident : backlog non vide + débit nul, ou âge du plus vieux job qui dépasse un seuil alors que tu as des workers déclarés actifs.
Avec SQS, l’âge du plus vieux message est une arme. Avec Horizon, le throughput par queue l’est aussi. Tu peux aussi ajouter un log structuré “processed_count” côté app, ou un metric Prometheus/StatsD incrémenté à chaque job terminé. L’important, c’est d’arrêter de confondre “infra OK” et “produit OK”.
Runbook incident : ce que je fais quand ça arrive (et que ça brûle)
Quand le symptôme apparaît, je ne pars pas en chasse au bug tout de suite. Je stabilise.
D’abord je confirme le diagnostic : backlog qui monte, âge qui monte, débit à 0. Ensuite je redémarre proprement les workers concernés, en gardant en tête que si la cause racine est un problème d’accès au provider (SQS/Redis), redémarrer ne suffira pas, ou ça tiendra 2 minutes. Je vérifie donc immédiatement les erreurs réseau, les logs d’auth (IAM/STS si AWS), les timeouts, les limites, et l’état de Redis si c’est Redis.
Pendant ce temps, je protège le produit : si certaines queues peuvent être mises en pause sans casser l’app (exemple : thumbnails, exports), je les coupe pour laisser respirer celles qui sont vitales (emails transactionnels, webhooks clients, paiement). Et si la queue a pris un retard énorme, je prépare déjà le plan de rattrapage, parce que “relancer” n’est pas “rattraper”. Beaucoup de systèmes se refont tomber si tu libères 200k jobs d’un coup.
Ce que je mettrais en place après l’incident (parce que ça reviendra)
Une fois que tu as vécu ce genre de loop silencieux, tu changes ton modèle mental. Tu assumes que “le worker peut mentir”. Donc tu ajoutes des garde-fous.
Je veux un redémarrage automatique sur non-progression (heartbeat), une alerte “no throughput” qui sonne avant que le support ne t’écrive, et une stratégie de backoff côté provider quand il y a des erreurs de connexion. SQS et Redis ont leurs modes de panne : tu dois les traiter comme des dépendances réseau, pas comme une file “locale” fiable.
Et si tu utilises Horizon, je garde une règle simple : Horizon est un très bon tableau de bord, mais ce n’est pas une preuve de traitement. Ce qui compte, c’est le débit réel et l’âge des jobs. Le reste, c’est du confort.
Conclusion : la santé d’une queue, c’est la progression
Ce bug est frustrant parce qu’il attaque un réflexe qu’on a tous : “si le process est up, c’est bon”. Pour les queues, c’est faux. Une queue en bonne santé, c’est une queue qui sort des jobs, régulièrement, de manière observable.
Si tu prends une seule action après avoir lu ça, fais-la simple : ajoute une métrique de throughput et une alerte sur l’âge du plus vieux job. Le jour où ton worker décidera de boucler en silence, tu le sauras tout de suite. Et tu ne perdras plus ta matinée à regarder des conteneurs “en pleine forme”.