---
title: "Files d'attente Laravel en prod : timeouts, failed_jobs | DevSense"
description: "Configurer les files d'attente Laravel pour la production : retry_after et --timeout, nouvelles tentatives et backoff, failed_jobs, mémoire des workers de longue durée, imports lourds via les batchs, jobs uniques, Supervisor et Horizon."
faq:
    - { question: "Quelle est la différence entre retry_after et --timeout dans les files d'attente Laravel ?", answer: "retry_after se définit dans config/queue.php et indique à la connexion au bout de combien de secondes un job réservé doit être considéré comme bloqué et redistribué. --timeout (ou l'attribut #[Timeout] du job) limite le nombre de secondes pendant lesquelles le worker laisse le job s'exécuter avant de s'arrêter en erreur. Le timeout doit être inférieur de quelques secondes à retry_after, sinon le job risque de s'exécuter deux fois." }
    - { question: "Pourquoi un job s'est-il exécuté deux fois ?", answer: "Le plus souvent parce que le job a duré plus longtemps que retry_after et que la connexion l'a confié à un second worker, ou parce que le worker a été tué en cours d'exécution (déploiement, OOM, SIGKILL) et que le job est revenu dans la file. Les jobs doivent donc être idempotents et les timeouts cohérents : client HTTP < timeout du job < retry_after." }
    - { question: "Pourquoi la mémoire d'un worker de file d'attente augmente-t-elle ?", answer: "Le worker queue:work est un processus de longue durée qui ne recharge pas le framework entre deux jobs. La mémoire s'accumule dans les tableaux statiques et les singletons à état, le journal des requêtes, Telescope, les images GD et les grosses collections. Limitez la durée de vie du worker avec les options --max-jobs, --max-time et --memory, et lancez-le sous Supervisor, qui relancera le processus." }
    - { question: 'Que faire des enregistrements de la table failed_jobs ?', answer: "Ne pas les relancer à l'aveugle. Commencer par examiner l'exception (queue:failed, Horizon), corriger la cause, puis relancer les jobs avec la commande queue:retry. Pour les notifications, utiliser la méthode failed() du job ou l'événement Queue::failing, et supprimer régulièrement les anciens enregistrements avec la commande queue:prune-failed planifiée." }
published: '2026-10-03'
---
# Files d'attente Laravel en production : timeouts, failed_jobs, mémoire et jobs lourds

En local, les files d'attente « marchent toutes seules » : `queue:work` dans un terminal voisin, des jobs exécutés en une seconde, aucune erreur. En production, on change de registre. Un client a reçu deux fois le même e-mail de facture. L'import d'une liste de prix de 400 000 lignes tourne depuis trois heures et a englouti deux gigaoctets de mémoire. Vingt mille enregistrements se sont accumulés dans `failed_jobs`, et personne ne sait lesquels sont importants. Après un déploiement, les workers exécutent pendant une semaine les jobs avec l'ancien code.

Cet article explique comment configurer les files d'attente Laravel pour qu'elles survivent aux pannes, aux déploiements et aux gros volumes : le lien entre `retry_after` et `--timeout`, le fonctionnement des nouvelles tentatives, que faire de `failed_jobs`, pourquoi la mémoire des workers augmente et comment découper les imports lourds. L'exécution locale des files d'attente dans Docker est traitée dans l'article [Sail : files d'attente et workers](../tools/sail-queues), et le choix du broker dans la [comparaison des files de messages](message-queues-compared).

**Voir aussi :** [Intégrations résilientes](resilient-external-integrations) · [Zero-downtime deployment](zero-downtime-deployment-laravel) · [Eloquent et gros volumes](eloquent-large-datasets) · [Observabilité et monitoring](observability-monitoring-laravel)

## Sommaire

* [Cycle de vie d'un job : tentatives, remise en file, échec](#lifecycle)
* [retry_after et --timeout : l'échelle des timeouts](#timeouts)
* [Nouvelles tentatives : tries, backoff, retryUntil](#retries)
* [failed_jobs : analyse, relance, nettoyage](#failed-jobs)
* [Mémoire des workers de longue durée](#memory)
* [Imports et exports lourds](#heavy-jobs)
* [Doublons et concurrence : jobs uniques et after_commit](#uniqueness)
* [Files séparées et priorités](#priorities)
* [Supervisor et Horizon](#supervisor)
* [Monitoring des files d'attente](#monitoring)
* [Erreurs fréquentes](#common-mistakes)
* [Checklist](#checklist)
* [Quiz d'auto-évaluation](#self-test-quiz)

---

<a id="lifecycle"></a>
## Cycle de vie d'un job : tentatives, remise en file, échec

Quand un worker prend un job, une **tentative** (attempt) commence. La tentative est consommée même si la méthode `handle()` ne s'est pas exécutée jusqu'au bout. D'après la documentation de Laravel, une tentative est « consommée » par :

* une exception non gérée dans le job ;
* une remise manuelle en file via `$this->release()` ;
* un middleware comme `WithoutOverlapping` ou `RateLimited` qui n'a pas obtenu le verrou et a remis le job en file ;
* un dépassement du timeout ;
* l'exécution réussie de `handle()`.

**Par défaut, Laravel ne fait qu'une seule tentative.** Si le job échoue, il est immédiatement considéré comme échoué et atterrit dans `failed_jobs`. Si vous utilisez `RateLimited` ou `WithoutOverlapping`, une seule tentative est presque à coup sûr insuffisante : le job « brûlera » dès la première remise en file, sans avoir commencé son travail.

```
dispatch → [queue] → worker reserves → handle()
                          │                 │
                          │         ┌───────┴────────┐
                          │      success      exception / timeout / release
                          │         │                │
                          │      delete      attempts < tries? ──yes──→ back to queue (after backoff)
                          │                          │
                          │                          no
                          │                          ▼
                          └── worker died ──→   failed() + failed_jobs
                              (job reappears after retry_after)
```

---

<a id="timeouts"></a>
## retry_after et --timeout : l'échelle des timeouts

Ce sont deux mécanismes distincts, faciles à confondre.

* **`retry_after`** est un paramètre de la connexion dans `config/queue.php`. Il répond à la question « au bout de combien de secondes considérer un job réservé comme abandonné et le confier à un autre worker ». Le broker ne sait pas si le worker est vivant ; il ne se fie qu'au temps écoulé. Chez Amazon SQS, c'est le Visibility Timeout défini dans les paramètres de la file qui joue ce rôle.
* **`--timeout`** de `queue:work` (ou l'attribut `#[Timeout]` du job) correspond au nombre de secondes pendant lesquelles le worker laisse le job s'exécuter. Par défaut : 60 secondes. Une fois ce délai écoulé, le processus du worker s'arrête en erreur et Supervisor en relance un nouveau. Les timeouts nécessitent l'extension **pcntl**.

Si `--timeout` est supérieur à `retry_after`, un job qui dure plus longtemps que `retry_after` sera **confié à un second worker alors que le premier l'exécute encore**. D'où les e-mails en double, les doubles débits et les situations de concurrence. La documentation énonce la règle sans détour : `--timeout` doit être inférieur d'au moins quelques secondes à `retry_after`.

En pratique, c'est toute une échelle de valeurs qu'il faut accorder, chacune supérieure à la précédente :

| Niveau | Où le définir | Exemple |
|---------|--------------|--------|
| Timeouts du client HTTP et SQL | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 s |
| Timeout du job | `#[Timeout(120)]` ou `--timeout=120` | 120 s |
| `retry_after` de la connexion | `config/queue.php` | 150 s |
| Attente de l'arrêt du worker | `stopwaitsecs` dans Supervisor, `stop_grace_period` dans Docker | 180 s |

```php
// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 150),
    'block_for' => 5,
    'after_commit' => true,
],
```

```php
// app/Jobs/SyncSupplierPrices.php
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(120)]
final class SyncSupplierPrices implements ShouldQueue
{
    use Queueable;

    public function handle(SupplierClient $client): void
    {
        // The HTTP client has its own, shorter timeout: blocking IO
        // (sockets, HTTP) may not be interrupted by the job timeout.
        $client->withTimeout(seconds: 20)->syncPrices();
    }
}
```

> [!WARNING]
> **Le timeout du job n'interrompt pas un socket bloqué.** La documentation le signale expressément : les E/S bloquantes (sockets, connexions HTTP sortantes) peuvent ne pas réagir au timeout du worker. Définissez toujours des timeouts propres au client HTTP et aux requêtes vers la base.

Pour Redis, `block_for` indique au driver combien de secondes attendre un nouveau job en mode bloquant. La valeur `0` bloque le worker indéfiniment : il cesse alors de traiter les signaux comme `SIGTERM` jusqu'à l'arrivée du job suivant, ce qui casse l'arrêt en douceur lors d'un déploiement.

Si un job ne doit pas être relancé après un timeout (par exemple parce qu'il a pu déjà envoyer des données à un système externe), marquez-le avec l'attribut `#[FailOnTimeout]` : le timeout fait alors immédiatement passer le job en échec, sans nouvelle tentative.

---

<a id="retries"></a>
## Nouvelles tentatives : tries, backoff, retryUntil

Dans Laravel 13, les paramètres de nouvelle tentative se définissent commodément via des attributs PHP directement sur la classe du job (dans les versions plus anciennes, via les propriétés `$tries`, `$timeout`, `$backoff`) :

```php
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;

#[Tries(10)]
#[MaxExceptions(3)]
#[Backoff([10, 60, 300])]
final class PushOrderToCrm implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function middleware(): array
    {
        // Releases caused by rate limiting consume attempts, hence Tries(10),
        // while MaxExceptions(3) fails the job after three real errors.
        return [new RateLimited('crm')];
    }

    public function handle(CrmClient $crm, OrderSummaryQuery $orders): void
    {
        $crm->upsertOrder($orders->summary($this->orderId));
    }
}
```

* **`Tries`** : nombre maximal de tentatives. La valeur définie sur la classe l'emporte sur l'option `--tries` du worker.
* **`MaxExceptions`** : nombre de véritables exceptions au-delà duquel le job échoue, même s'il reste des tentatives. Cela permet d'autoriser de nombreuses remises en file dues au rate limit, sans pour autant marteler dix fois une API en panne.
* **`Backoff`** : pause avant une nouvelle tentative après une exception. Le tableau `[10, 60, 300]` définit des pauses croissantes : 10 secondes, une minute, puis cinq minutes pour la troisième tentative et les suivantes.
* **`retryUntil()`** : alternative au nombre de tentatives, qui consiste à réessayer autant de fois que nécessaire, mais pas au-delà d'un instant donné. Si `tries` et `retryUntil()` sont tous deux définis, `retryUntil()` est prioritaire.

```php
public function retryUntil(): DateTime
{
    return now()->addMinutes(30);
}
```

Pour les API externes instables, le middleware `ThrottlesExceptions` est utile : après une série d'erreurs, il reporte les jobs d'une durée donnée au lieu de brûler des tentatives sur un service manifestement hors service. C'est une implémentation du pattern Circuit Breaker au niveau de la file d'attente (en détail dans l'article sur les [intégrations résilientes](resilient-external-integrations)).

```php
public function middleware(): array
{
    return [(new ThrottlesExceptions(10, 5 * 60))->backoff(5)];
}
```

> [!NOTE]
> **Une nouvelle tentative n'est sûre que si le job est idempotent.** Un job peut être réexécuté après un timeout, un crash du worker ou votre `queue:retry`. Utilisez `upsert` plutôt que `insert`, les clés d'idempotence des API de paiement et une vérification « déjà fait ? » au début de `handle()`.

---

<a id="failed-jobs"></a>
## failed_jobs : analyse, relance, nettoyage

Quand les tentatives sont épuisées, Laravel appelle la méthode `failed()` du job et l'enregistre dans la table `failed_jobs`, avec le payload et le texte de l'exception.

```php
public function failed(?Throwable $exception): void
{
    // Runs in the worker after the last attempt: notify, compensate, mark state.
    Order::whereKey($this->orderId)->update(['crm_sync_status' => 'failed']);

    report($exception);
}
```

Le cycle de travail avec les jobs échoués :

```bash
php artisan queue:failed                 # list failed jobs with exception summaries
php artisan queue:retry <uuid>           # retry one job after fixing the cause
php artisan queue:retry --queue=crm      # retry every failed job from one queue
php artisan queue:retry all              # retry everything (use with care)
php artisan queue:forget <uuid>          # delete one record
php artisan queue:prune-failed --hours=168
```

Des règles qui épargnent bien des nuits blanches :

* **Ne lancez pas `queue:retry all` à l'aveugle.** Si la cause n'est pas corrigée, les jobs échoueront de nouveau, et les jobs non idempotents auront eu le temps de refaire quelque chose une seconde fois.
* **Nettoyez la table de façon planifiée.** Par défaut, `queue:prune-failed` supprime les enregistrements de plus de 24 heures ; ajustez `--hours` à votre processus d'analyse.
* **Supprimez les jobs dont les modèles ont été supprimés.** Si le modèle a été supprimé pendant que le job attendait dans la file, l'attribut `#[DeleteWhenMissingModels]` supprimera discrètement le job au lieu de le faire échouer avec une `ModelNotFoundException`.
* **Alertez non pas sur chaque erreur, mais sur la tendance.** Un job échoué par heure, c'est du bruit ; cent par minute, c'est un incident. L'événement `Queue::failing()` se prête bien à l'alimentation d'un compteur de métriques.

```php
// routes/console.php
Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();
```

---

<a id="memory"></a>
## Mémoire des workers de longue durée

`queue:work` est un démon : il charge l'application une seule fois et exécute les jobs en boucle, sans redémarrer le framework. C'est rapide, mais tout ce qu'un job a laissé en mémoire y reste jusqu'à la fin de vie du processus. Sources typiques de croissance :

* les tableaux statiques et les singletons qui accumulent de l'état (un « cache » dans une propriété de service) ;
* le journal des requêtes (`DB::enableQueryLog()`), Telescope et Debugbar en production ;
* les ressources GD/Imagick sans `imagedestroy()` ni `clear()` ;
* les grosses collections chargées en entier via `get()` au lieu de `lazyById()`.

La parade : limiter la durée de vie du worker et laisser le gestionnaire de processus le relancer :

```bash
php artisan queue:work redis --queue=default \
    --tries=3 --timeout=120 \
    --max-jobs=1000 --max-time=3600 --memory=256
```

* `--max-jobs` : s'arrêter après N jobs ;
* `--max-time` : s'arrêter après N secondes de fonctionnement ;
* `--memory` : s'arrêter si, après un job, le processus occupe plus de N mégaoctets (128 par défaut).

Il est important de comprendre l'ordre des choses : `--memory` est vérifié **entre** les jobs. Si un job dépasse à lui seul le `memory_limit` de PHP, le processus plantera sur une erreur fatale en pleine exécution, le job ne reviendra dans la file qu'après `retry_after`, et plantera de nouveau. De tels jobs ne se « soignent » pas avec des limites : il faut les réécrire, avec un traitement en flux et un découpage en morceaux.

---

<a id="heavy-jobs"></a>
## Imports et exports lourds

Un job unique « importer un fichier de 400 000 lignes » cumule tout : exécution longue, risque de timeout, croissance de la mémoire et zéro progression en cas de plantage à la 399 999e ligne. Le schéma qui fonctionne : découper le travail en nombreux petits jobs idempotents et les regrouper dans un **batch**.

```php
// app/Domain/Catalog/Actions/StartPriceImport.php
public function handle(string $path, int $userId): Batch
{
    $jobs = [];
    foreach ($this->reader->chunkOffsets($path, rowsPerChunk: 2000) as [$from, $to]) {
        $jobs[] = new ImportPriceChunk($path, $from, $to);
    }

    return Bus::batch($jobs)
        ->name("price-import:{$userId}")
        ->onQueue('imports')
        ->allowFailures()
        ->then(fn (Batch $batch) => PriceImportFinished::dispatch($userId, $batch->id))
        ->catch(fn (Batch $batch, Throwable $e) => report($e))
        ->finally(fn (Batch $batch) => Storage::delete($path))
        ->dispatch();
}
```

```php
// app/Jobs/ImportPriceChunk.php
use Illuminate\Bus\Batchable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(90)]
final class ImportPriceChunk implements ShouldQueue
{
    use Batchable, Queueable;

    public function __construct(
        public string $path,
        public int $fromRow,
        public int $toRow,
    ) {}

    public function handle(PriceFileReader $reader): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        $rows = $reader->rows($this->path, $this->fromRow, $this->toRow);

        // Idempotent: re-running the same chunk overwrites the same SKUs.
        DB::table('product_prices')->upsert($rows, uniqueBy: ['sku'], update: ['price_cents', 'updated_at']);
    }
}
```

Les points importants :

* **On passe au job les coordonnées du travail, pas les données.** Le chemin du fichier et la plage de lignes pèsent quelques octets. Un tableau de deux mille lignes dans le constructeur ferait gonfler le payload, la mémoire de Redis et la table `failed_jobs`.
* **Chaque morceau est idempotent.** Un `upsert` sur `sku` donne le même résultat en cas de relance.
* **La progression est visible.** `$batch->progress()`, `processedJobs()` et `failedJobs` peuvent être affichés à l'utilisateur. Les batchs nécessitent la table `job_batches` (`php artisan make:queue-batches-table`), qu'il faut elle aussi nettoyer avec la commande `queue:prune-batches`.
* **`allowFailures()`** évite d'annuler tout l'import à cause d'un seul morceau défectueux ; sans lui, la première erreur annule le batch.
* **Une file `imports` dédiée**, avec ses propres workers, empêche l'import de retarder les e-mails et les notifications.

Même logique pour l'export : le job lit les données en flux via `lazyById()` (voir [Eloquent et gros volumes](eloquent-large-datasets#export)), écrit le fichier dans le stockage et envoie un lien à l'utilisateur.

Si un job reçoit un modèle, Laravel le sérialise avec ses relations chargées. L'attribut `#[WithoutRelations]` (ou `$model->withoutRelations()`) ne laisse que l'identifiant dans le payload : le modèle sera rechargé depuis la base au moment de l'exécution.

---

<a id="uniqueness"></a>
## Doublons et concurrence : jobs uniques et after_commit

**Le job démarre avant que les données soient commitées.** Bug classique : le job est dispatché à l'intérieur d'une transaction, un worker le prend instantanément, cherche la commande par son ID… alors que la transaction n'est pas encore commitée. Résultat : `ModelNotFoundException`, ou pire, le job travaille sur des données obsolètes. Solutions :

* `'after_commit' => true` dans la configuration de la connexion : tous les jobs, événements en file, e-mails et notifications attendent le commit ;
* l'interface `ShouldQueueAfterCommit` sur un job donné ;
* `->afterCommit()` au moment du dispatch.

**Le même job plusieurs fois dans la file.** L'utilisateur a cliqué trois fois sur « Recalculer », et la file contient trois jobs lourds identiques. L'interface `ShouldBeUnique` empêche d'en ajouter un deuxième tant que le premier n'est pas terminé :

```php
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(3600)]
final class RecalculateCustomerStats implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public int $customerId) {}

    public function uniqueId(): string
    {
        return (string) $this->customerId;
    }
}
```

L'unicité repose sur un verrou atomique dans le cache (Redis, base de données, memcached). Le verrou est libéré après l'exécution ou l'échec définitif du job ; `ShouldBeUniqueUntilProcessing` le libère avant le début de l'exécution, ce qui permet de mettre en file le job suivant pendant que le job courant s'exécute. L'unicité ne s'applique pas à l'intérieur des batchs.

**Deux jobs différents modifient les mêmes données en même temps.** C'est là qu'intervient le middleware `WithoutOverlapping` avec une clé par entité : les jobs partageant la même clé s'exécutent l'un après l'autre.

```php
public function middleware(): array
{
    return [(new WithoutOverlapping($this->customerId))->releaseAfter(30)->expireAfter(180)];
}
```

En pratique, `expireAfter` est indispensable : si le worker meurt en plein job, un verrou sans durée de vie restera en place pour toujours.

---

<a id="priorities"></a>
## Files séparées et priorités

Une seule file `default` pour tout est une cause fréquente de plaintes du type « l'e-mail de réinitialisation du mot de passe est arrivé au bout de vingt minutes » : dix mille jobs d'import attendaient devant lui. Séparez les jobs selon la nature de leur charge :

* `high` : ce que l'utilisateur attend, à savoir e-mails de réinitialisation du mot de passe, notifications, webhooks de paiement ;
* `default` : les jobs d'arrière-plan ordinaires ;
* `imports` / `reports` : les jobs lourds et longs, avec leurs propres timeouts et limites mémoire.

Le worker traite les files par priorité, de gauche à droite : `--queue=high,default`. Pour les files lourdes, lancez des workers dédiés avec d'autres valeurs de `--timeout` et `--memory`, et donc une connexion distincte avec un `retry_after` adapté.

Dans Laravel 13, le routage des jobs vers les files peut être centralisé en un seul endroit, au lieu d'un `->onQueue()` à chaque appel :

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::route([
        SendPasswordResetLink::class => 'high',
        ImportPriceChunk::class => ['redis-long', 'imports'],
    ]);
}
```

---

<a id="supervisor"></a>
## Supervisor et Horizon

Les workers doivent tourner en permanence et redémarrer après un crash, un `--max-time` ou un `queue:restart`. Sans gestionnaire de processus, cela n'arrivera pas.

```ini
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis --queue=high,default --tries=3 --timeout=120 --max-time=3600 --memory=256
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/worker.log
stopwaitsecs=180

[program:laravel-imports]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis-long --queue=imports --timeout=600 --memory=512
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/imports.log
stopwaitsecs=660
```

`stopwaitsecs` doit être supérieur au job le plus long de ce groupe : à l'arrêt, Supervisor envoie `SIGTERM`, le worker termine le job en cours, et ce n'est qu'à l'expiration de `stopwaitsecs` que le processus est tué par `SIGKILL`. Dans Docker, c'est `stop_grace_period` qui joue ce rôle.

**Horizon** est plus pratique pour les files sur Redis : configuration des workers dans le code, répartition automatique des processus entre les files, dashboard avec métriques, jobs échoués et tags.

```php
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['high', 'default'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 2,
            'maxProcesses' => 10,
            'tries' => 3,
            'timeout' => 120,
            'memory' => 256,
            'maxTime' => 3600,
            'maxJobs' => 1000,
        ],
        'supervisor-imports' => [
            'connection' => 'redis-long',
            'queue' => ['imports'],
            'balance' => false,
            'maxProcesses' => 2,
            'timeout' => 600,
            'memory' => 512,
        ],
    ],
],
```

La règle des timeouts s'applique ici aussi : le `timeout` d'un superviseur Horizon doit être inférieur de quelques secondes au `retry_after` de la connexion, tout en restant supérieur au timeout de chaque job pris isolément. Les métriques d'Horizon reposent sur des snapshots : ajoutez `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Déploiement.** Les workers gardent le code en mémoire et ne voient pas les changements sans redémarrage. Après la bascule de release, exécutez `php artisan queue:restart` (ou `php artisan horizon:terminate`) : les workers termineront les jobs en cours puis s'arrêteront, et Supervisor les relancera avec le nouveau code. Le signal de redémarrage est stocké dans le cache, qui doit donc être partagé par tous les serveurs. La compatibilité du payload des anciens jobs avec le nouveau code est un sujet à part, traité dans l'article sur le [déploiement sans interruption](zero-downtime-deployment-laravel#queues).

---

<a id="monitoring"></a>
## Monitoring des files d'attente

Les files d'attente tombent en panne en silence : le site fonctionne, pas d'erreurs 500, mais les e-mails ne partent plus depuis trois heures. Jeu minimal de signaux :

* **Longueur de la file et temps d'attente** : une file qui s'allonge signifie que les workers sont trop peu nombreux ou bloqués.
* **Taux d'échec** : le nombre d'enregistrements dans `failed_jobs` sur un intervalle donné.
* **Disponibilité des workers** : Horizon affiche l'état des superviseurs ; sans Horizon, surveillez les processus via Supervisor.

La commande intégrée `queue:monitor` vérifie la taille des files et, en cas de dépassement du seuil, émet l'événement `QueueBusy`, auquel on peut rattacher une notification :

```php
// routes/console.php
Schedule::command('queue:monitor redis:high,redis:default --max=500')->everyMinute();
```

Plus de détails sur les métriques, les logs et les alertes dans l'article sur l'[observabilité](observability-monitoring-laravel).

---

<a id="common-mistakes"></a>
## Erreurs fréquentes

**1. `--timeout` supérieur à `retry_after`.**
Un job long est confié à un second worker pendant que le premier l'exécute. Résultat : doublons et situations de concurrence.

**2. Pas de timeout sur le client HTTP.**
Un socket bloqué n'est pas toujours interrompu par le timeout du job. Le worker est figé, la file s'allonge.

**3. Une seule tentative par défaut combinée à `RateLimited` ou `WithoutOverlapping`.**
La toute première remise en file fait échouer le job. Augmentez `Tries` et limitez les erreurs via `MaxExceptions`.

**4. Des jobs non idempotents.**
Une relance après un timeout, un crash du worker ou un `queue:retry` envoie un deuxième e-mail ou effectue un deuxième débit.

**5. Des données dans le payload au lieu d'identifiants.**
Les gros tableaux et les modèles avec leurs relations font gonfler Redis et `failed_jobs`. Transmettez des ID et des chemins de fichiers.

**6. Un job dispatché depuis une transaction sans `after_commit`.**
Le worker ne trouve pas l'enregistrement pas encore commité, ou travaille sur des données obsolètes.

**7. Des workers sans limite de durée de vie.**
La mémoire grossit pendant des semaines, jusqu'à ce que l'OOM killer tue le processus en plein job. Utilisez `--max-jobs`, `--max-time`, `--memory`.

**8. Oublier `queue:restart` après un déploiement.**
Les workers exécutent les jobs avec l'ancien code face au nouveau schéma de base.

**9. `queue:retry all` sans analyser les causes.**
Les jobs échouent de nouveau, et les non idempotents ont le temps de répéter leurs effets de bord.

---

<a id="checklist"></a>
## Checklist

1. L'échelle des timeouts est cohérente : HTTP/SQL < timeout du job < `retry_after` < `stopwaitsecs` / `stop_grace_period`.
2. L'extension pcntl est installée ; pour Redis, `block_for` n'est pas égal à `0`.
3. Chaque job a des valeurs `Tries`, `Backoff` et, si nécessaire, `MaxExceptions` ou `retryUntil()` choisies en connaissance de cause.
4. Les jobs sont idempotents ; les appels externes utilisent des clés d'idempotence.
5. Les jobs sont dispatchés après le commit (`after_commit` ou `ShouldQueueAfterCommit`).
6. Le payload contient des identifiants et des chemins, pas de gros volumes de données ; les modèles sont sans relations.
7. Les imports lourds sont découpés en batchs, avec une file dédiée et leurs propres workers.
8. Les workers sont redémarrés via `--max-jobs`, `--max-time`, `--memory` sous Supervisor ou Horizon.
9. `queue:restart` / `horizon:terminate` est une étape obligatoire du déploiement.
10. `queue:prune-failed` et `queue:prune-batches` sont planifiés, et des alertes sont configurées sur la taille des files et le taux d'échec.

---

## En résumé

En production, les files d'attente sont aussi fiables que leurs valeurs sont cohérentes et leurs jobs idempotents. Les timeouts s'échelonnent, les nouvelles tentatives s'adaptent à la nature de l'erreur, le travail lourd est découpé en petits morceaux rejouables, et les workers ont une durée de vie limitée et redémarrent à chaque déploiement. `failed_jobs` cesse alors d'être un cimetière pour devenir une liste de travail que l'on peut analyser et rejouer.

---

<a id="self-test-quiz"></a>
## Quiz d'auto-évaluation

### Question 1 : Le `retry_after` de la connexion vaut 90 secondes, le worker est lancé avec `--timeout=300`. Un job s'exécute pendant 200 secondes. Que se passe-t-il ?
- A) Le job se termine tranquillement : le worker dispose d'une marge sur le timeout.
- B) Au bout de 90 secondes, la connexion confie le job à un autre worker, et il commence à s'exécuter une seconde fois en parallèle du premier.
- C) Le worker interrompt le job au bout de 90 secondes.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
`retry_after` est décompté par le broker, qui ne sait pas si le worker est vivant. Au bout de 90 secondes, le job est considéré comme abandonné et redistribué. C'est pourquoi `--timeout` doit être inférieur de quelques secondes à `retry_after`.
</details>

### Question 2 : Un job utilise le middleware `RateLimited` et atterrit dans `failed_jobs` sans avoir exécuté `handle()` une seule fois. Pourquoi ?
- A) `RateLimited` ne fonctionne pas avec le driver Redis.
- B) La remise en file due à la limite consomme une tentative, et par défaut un job n'a qu'une seule tentative.
- C) Le middleware s'exécute après `handle()`.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
Chaque `release()` est une tentative consommée. Pour les jobs soumis au rate limiting, on augmente `Tries` (ou l'on utilise `retryUntil()`) et l'on limite le nombre d'erreurs réelles via `MaxExceptions`.
</details>

### Question 3 : Comment importer correctement un fichier de 400 000 lignes via une file d'attente ?
- A) Un seul job avec `#[Timeout(7200)]` et `--memory=4096` sur le worker.
- B) Un batch de petits jobs idempotents, chacun traitant sa propre plage de lignes, dans une file dédiée avec ses propres workers.
- C) Passer toutes les lignes du fichier au constructeur du job, pour que le worker n'ait pas à lire le fichier.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
Les petits morceaux tiennent dans les timeouts et les limites mémoire, se rejouent indépendamment et rendent la progression visible. La variante A perd tout le travail en cas de plantage à la dernière ligne, et la variante C fait gonfler le payload, la mémoire de Redis et `failed_jobs`.
</details>