---
title: 'Code Laravel in produzione: timeout e failed_jobs | DevSense'
description: 'Come configurare le code di Laravel per la produzione: retry_after e --timeout, retry e backoff, failed_jobs, memoria dei worker a lunga esecuzione, import pesanti tramite batch, job univoci, Supervisor e Horizon.'
faq:
    - { question: "Che differenza c'è tra retry_after e --timeout nelle code di Laravel?", answer: "retry_after si imposta in config/queue.php e indica alla connessione dopo quanti secondi considerare bloccato un job già prelevato e consegnarlo di nuovo. --timeout (o l'attributo #[Timeout] sul job) limita quanti secondi il worker concede al job per l'esecuzione prima di terminare con un errore. Il timeout deve essere di qualche secondo inferiore a retry_after, altrimenti il job può essere eseguito due volte." }
    - { question: 'Perché un job in coda è stato eseguito due volte?', answer: "Il più delle volte perché il job è durato più di retry_after e la connessione lo ha consegnato a un secondo worker, oppure perché il worker è stato ucciso durante l'esecuzione (deploy, OOM, SIGKILL) e il job è tornato in coda. Per questo i job devono essere idempotenti e i timeout coerenti tra loro: client HTTP < timeout del job < retry_after." }
    - { question: 'Perché cresce la memoria del worker della coda?', answer: "Il worker queue:work è un processo a lunga esecuzione che non ricarica il framework tra un job e l'altro. La memoria si accumula in array statici e singleton con stato, nel query log, in Telescope, nelle immagini GD e nelle collection di grandi dimensioni. Limitate la vita del worker con i flag --max-jobs, --max-time e --memory ed eseguitelo sotto Supervisor, che rialzerà il processo." }
    - { question: 'Cosa fare con i record della tabella failed_jobs?', answer: "Non rilanciarli alla cieca. Prima esaminate l'eccezione (queue:failed, Horizon), correggete la causa, poi ripetete i job con il comando queue:retry. Per le notifiche usate il metodo failed() del job o l'evento Queue::failing, e cancellate regolarmente i record vecchi con il comando queue:prune-failed schedulato." }
published: '2026-10-03'
---
# Code di Laravel in produzione: timeout, failed_jobs, memoria e job pesanti

In locale le code «funzionano e basta»: `queue:work` nel terminale accanto, i job vengono eseguiti in un secondo, nessun errore. In produzione iniziano storie di tutt'altro genere. Al cliente arriva due volte la stessa email con la fattura. L'import di un listino da 400 mila righe gira da tre ore e si è mangiato due gigabyte di memoria. In `failed_jobs` si sono accumulati ventimila record e nessuno sa quali siano importanti. Dopo il deploy, i worker eseguono i job con il codice vecchio per una settimana.

Questo articolo spiega come configurare le code di Laravel perché sopravvivano a guasti, deploy e grandi volumi: come sono legati `retry_after` e `--timeout`, come funzionano i retry, cosa fare con `failed_jobs`, perché cresce la memoria dei worker e come spezzare gli import pesanti. L'esecuzione locale delle code in Docker è trattata nell'articolo [Sail: code e worker](../tools/sail-queues), mentre la scelta del broker è nel [confronto tra code di messaggi](message-queues-compared).

**Materiali correlati:** [Integrazioni resilienti](resilient-external-integrations) · [Zero-downtime deployment](zero-downtime-deployment-laravel) · [Eloquent e grandi dataset](eloquent-large-datasets) · [Osservabilità e monitoraggio](observability-monitoring-laravel)

## Indice

* [Ciclo di vita di un job: tentativi, rilascio, fallimento](#lifecycle)
* [retry_after e --timeout: la scala dei timeout](#timeouts)
* [Retry: tries, backoff, retryUntil](#retries)
* [failed_jobs: analisi, retry, pulizia](#failed-jobs)
* [Memoria dei worker a lunga esecuzione](#memory)
* [Import ed export pesanti](#heavy-jobs)
* [Duplicati e race condition: job univoci e after_commit](#uniqueness)
* [Code separate e priorità](#priorities)
* [Supervisor e Horizon](#supervisor)
* [Monitoraggio delle code](#monitoring)
* [Errori comuni](#common-mistakes)
* [Checklist](#checklist)
* [Quiz di autovalutazione](#self-test-quiz)

---

<a id="lifecycle"></a>
## Ciclo di vita di un job: tentativi, rilascio, fallimento

Quando un worker preleva un job, inizia un **tentativo** (attempt). Il tentativo viene consumato anche se il metodo `handle()` non è arrivato in fondo. Secondo la documentazione di Laravel, un tentativo viene «bruciato» da:

* un'eccezione non gestita nel job;
* un rilascio manuale in coda tramite `$this->release()`;
* middleware come `WithoutOverlapping` o `RateLimited` che non hanno ottenuto il lock e hanno rimesso il job in coda;
* il superamento del timeout;
* l'esecuzione con successo di `handle()`.

**Per impostazione predefinita Laravel fa un solo tentativo.** Se il job fallisce, viene subito considerato fallito e finisce in `failed_jobs`. Se usate `RateLimited` o `WithoutOverlapping`, un solo tentativo quasi certamente non basta: il job «brucerà» al primo rilascio, senza nemmeno iniziare a lavorare.

```
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 e --timeout: la scala dei timeout

Sono due meccanismi diversi, facili da confondere.

* **`retry_after`** è un parametro della connessione in `config/queue.php`. Risponde alla domanda «dopo quanti secondi considerare abbandonato un job prelevato e consegnarlo a un altro worker». Il broker non sa se il worker è vivo, quindi si basa solo sul tempo. Su Amazon SQS al suo posto c'è il Visibility Timeout nelle impostazioni della coda.
* **`--timeout`** di `queue:work` (o l'attributo `#[Timeout]` sul job) indica quanti secondi il worker concede al job per l'esecuzione. Il default è 60 secondi. Se il tempo scade, il processo del worker termina con un errore e Supervisor ne avvia uno nuovo. Per i timeout serve l'estensione **pcntl**.

Se `--timeout` è maggiore di `retry_after`, un job che dura più di `retry_after` verrà **consegnato a un secondo worker mentre il primo lo sta ancora eseguendo**. Da qui email duplicate, doppi addebiti e race condition. La documentazione formula la regola in modo esplicito: `--timeout` deve essere almeno qualche secondo più corto di `retry_after`.

In pratica bisogna coordinare un'intera scala di valori, in cui ciascuno è maggiore del precedente:

| Livello | Dove si imposta | Esempio |
|---------|--------------|--------|
| Timeout del client HTTP e SQL | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 s |
| Timeout del job | `#[Timeout(120)]` o `--timeout=120` | 120 s |
| `retry_after` della connessione | `config/queue.php` | 150 s |
| Attesa dell'arresto del worker | `stopwaitsecs` in Supervisor, `stop_grace_period` in 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]
> **Il timeout del job non interrompe un socket bloccato.** La documentazione lo segnala esplicitamente: l'I/O bloccante (socket, connessioni HTTP in uscita) può non reagire al timeout del worker. Impostate sempre timeout propri sul client HTTP e sulle query al database.

`block_for` di Redis indica al driver quanti secondi attendere un nuovo job in modalità bloccante. Il valore `0` blocca il worker all'infinito, e il worker smette di gestire segnali come `SIGTERM` fino all'arrivo del job successivo, il che rompe l'arresto graceful durante il deploy.

Se un job non può essere ripetuto dopo un timeout (per esempio perché potrebbe aver già inviato dati a un sistema esterno), marcatelo con l'attributo `#[FailOnTimeout]`: in questo modo il timeout manda subito il job tra quelli falliti, senza retry.

---

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

In Laravel 13 i parametri dei retry si impostano comodamente con attributi PHP direttamente sulla classe del job (nelle versioni precedenti, con le proprietà `$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`**: numero massimo di tentativi. Il valore sulla classe ha la precedenza sul flag `--tries` del worker.
* **`MaxExceptions`**: dopo quante eccezioni reali il job fallisce, anche se restano tentativi. Permette di consentire molti rilasci dovuti al rate limit, senza martellare dieci volte un'API rotta.
* **`Backoff`**: pausa prima del retry dopo un'eccezione. L'array `[10, 60, 300]` imposta pause crescenti: 10 secondi, un minuto, cinque minuti per il terzo retry e i successivi.
* **`retryUntil()`**: alternativa al numero di tentativi: ripetere quante volte si vuole, ma non oltre un certo momento. Se sono impostati sia `tries` sia `retryUntil()`, ha la priorità `retryUntil()`.

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

Per API esterne instabili è utile il middleware `ThrottlesExceptions`: dopo una serie di errori rimanda i job per un tempo stabilito, invece di bruciare tentativi su un servizio notoriamente giù. È un'implementazione del pattern Circuit Breaker a livello di coda (in dettaglio nell'articolo sulle [integrazioni resilienti](resilient-external-integrations)).

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

> [!NOTE]
> **Un retry è sicuro solo se il job è idempotente.** Un job può essere rieseguito dopo un timeout, un crash del worker o un vostro `queue:retry`. Usate `upsert` invece di `insert`, le chiavi di idempotenza delle API di pagamento e un controllo «è già stato fatto?» all'inizio di `handle()`.

---

<a id="failed-jobs"></a>
## failed_jobs: analisi, retry, pulizia

Quando i tentativi sono finiti, Laravel chiama il metodo `failed()` del job e lo registra nella tabella `failed_jobs` insieme al payload e al testo dell'eccezione.

```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);
}
```

Il flusso di lavoro con i job falliti:

```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
```

Regole che risparmiano parecchi grattacapi:

* **Non lanciate `queue:retry all` alla cieca.** Se la causa non è stata corretta, i job falliranno di nuovo, e quelli non idempotenti faranno in tempo a ripetere qualcosa.
* **Pulite la tabella in modo schedulato.** Per impostazione predefinita `queue:prune-failed` elimina i record più vecchi di 24 ore; impostate `--hours` in base al vostro processo di analisi.
* **Eliminate i job con model cancellati.** Se il model è stato eliminato mentre il job aspettava in coda, l'attributo `#[DeleteWhenMissingModels]` eliminerà silenziosamente il job invece di farlo fallire con `ModelNotFoundException`.
* **Fate alerting sul trend, non su ogni errore.** Un job fallito all'ora è rumore, cento al minuto sono un incidente. L'evento `Queue::failing()` è comodo per alimentare un contatore di metriche.

```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>
## Memoria dei worker a lunga esecuzione

`queue:work` è un demone: carica l'applicazione una sola volta ed esegue i job in un ciclo, senza riavviare il framework. È veloce, ma tutto ciò che un job lascia in memoria ci resta fino alla fine della vita del processo. Fonti tipiche di crescita:

* array statici e singleton che accumulano stato (una «cache» in una proprietà di un servizio);
* il query log (`DB::enableQueryLog()`), Telescope e Debugbar in produzione;
* risorse GD/Imagick senza `imagedestroy()` o `clear()`;
* collection di grandi dimensioni caricate per intero con `get()` invece di `lazyById()`.

La difesa è limitare la vita del worker e lasciare che un process manager lo riavvii:

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

* `--max-jobs`: termina dopo N job;
* `--max-time`: termina dopo N secondi di lavoro;
* `--memory`: termina se, dopo un job, il processo occupa più di N megabyte (il default è 128).

È importante capire l'ordine: `--memory` viene controllato **tra** un job e l'altro. Se un singolo job supera da solo il `memory_limit` di PHP, il processo cade con un errore fatale a metà esecuzione, il job tornerà in coda solo dopo `retry_after`, e cadrà di nuovo. Questi job non vanno «curati» con i limiti, ma riscritti: elaborazione in streaming e suddivisione in parti.

---

<a id="heavy-jobs"></a>
## Import ed export pesanti

Un unico job «importa un file da 400 000 righe» racchiude tutto insieme: esecuzione lunga, rischio di timeout, crescita della memoria e progresso nullo se cade alla riga 399 999. Lo schema che funziona è suddividere il lavoro in tanti piccoli job idempotenti e riunirli in 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']);
    }
}
```

Cosa conta qui:

* **Al job si passano le coordinate del lavoro, non i dati.** Il percorso del file e l'intervallo di righe pesano pochi byte. Un array di duemila righe nel costruttore gonfierebbe il payload, la memoria di Redis e la tabella `failed_jobs`.
* **Ogni parte è idempotente.** Un `upsert` per `sku` dà lo stesso risultato in caso di retry.
* **Il progresso è visibile.** `$batch->progress()`, `processedJobs()` e `failedJobs` si possono mostrare all'utente. I batch richiedono la tabella `job_batches` (`php artisan make:queue-batches-table`), che va anch'essa pulita con il comando `queue:prune-batches`.
* **`allowFailures()`** evita di annullare l'intero import per una sola parte corrotta; senza, il primo errore annulla il batch.
* **Una coda `imports` separata**, con i propri worker, impedisce all'import di ritardare email e notifiche.

La stessa logica vale per l'export: il job legge i dati in streaming con `lazyById()` (vedi [Eloquent e grandi dataset](eloquent-large-datasets#export)), scrive il file nello storage e invia all'utente un link.

Se un job riceve un model, Laravel lo serializza insieme alle relazioni caricate. L'attributo `#[WithoutRelations]` (o `$model->withoutRelations()`) lascia nel payload solo l'identificativo: il model verrà ricaricato dal database al momento dell'esecuzione.

---

<a id="uniqueness"></a>
## Duplicati e race condition: job univoci e after_commit

**Il job parte prima che i dati siano committati.** Un bug classico: il job viene inviato dentro una transazione, il worker lo preleva all'istante, cerca l'ordine per ID, ma la transazione non è ancora stata committata. `ModelNotFoundException`, o peggio, il job lavora su dati vecchi. Soluzioni:

* `'after_commit' => true` nelle impostazioni della connessione: tutti i job, gli eventi in coda, le email e le notifiche attendono il commit;
* l'interfaccia `ShouldQueueAfterCommit` su un job specifico;
* `->afterCommit()` al momento del dispatch.

**Lo stesso job in coda più volte.** L'utente ha cliccato tre volte su «Ricalcola», e in coda ci sono tre job pesanti identici. L'interfaccia `ShouldBeUnique` non permette di accodarne un secondo finché il primo non è stato eseguito:

```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'univocità si basa su un lock atomico nella cache (Redis, database, memcached). Il lock viene rilasciato dopo l'esecuzione o il fallimento definitivo del job; `ShouldBeUniqueUntilProcessing` lo rilascia prima dell'inizio dell'esecuzione, permettendo di accodare il job successivo mentre quello corrente è in corso. All'interno dei batch l'univocità non si applica.

**Due job diversi modificano gli stessi dati contemporaneamente.** Qui aiuta il middleware `WithoutOverlapping` con una chiave per entità: i job con la stessa chiave vengono eseguiti uno dopo l'altro.

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

In pratica `expireAfter` è obbligatorio: se il worker muore a metà job, un lock senza scadenza resterà lì per sempre.

---

<a id="priorities"></a>
## Code separate e priorità

Un'unica coda `default` per tutto è una causa frequente di lamentele del tipo «l'email per il reset della password è arrivata dopo venti minuti»: davanti c'erano diecimila job di import. Separate i job in base al tipo di carico:

* `high`: ciò che l'utente sta aspettando: email di reset della password, notifiche, webhook di pagamento;
* `default`: i normali job in background;
* `imports` / `reports`: job pesanti e lunghi, con i propri timeout e limiti di memoria.

Il worker elabora le code per priorità, da sinistra a destra: `--queue=high,default`. Per le code pesanti avviate worker separati con `--timeout` e `--memory` diversi, e di conseguenza anche una connessione separata con un `retry_after` adeguato.

In Laravel 13 il routing dei job verso le code si può raccogliere in un unico punto, invece di usare `->onQueue()` in ogni chiamata:

```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 e Horizon

I worker devono girare costantemente e ripartire dopo un crash, dopo `--max-time` o dopo `queue:restart`. Senza un process manager questo non succede.

```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` deve essere maggiore del job più lungo di quel gruppo: all'arresto Supervisor invia `SIGTERM`, il worker completa il job corrente, e solo allo scadere di `stopwaitsecs` il processo viene ucciso con `SIGKILL`. In Docker lo stesso ruolo lo svolge `stop_grace_period`.

**Horizon** è più comodo per le code su Redis: configurazione dei worker nel codice, bilanciamento automatico dei processi tra le code, dashboard con metriche, job falliti e tag.

```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 regola sui timeout vale anche qui: il `timeout` del supervisor di Horizon deve essere di qualche secondo inferiore al `retry_after` della connessione e, allo stesso tempo, maggiore del timeout di qualsiasi singolo job. Le metriche di Horizon si basano su snapshot: aggiungete `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Deploy.** I worker tengono il codice in memoria e non vedono le modifiche senza un riavvio. Dopo lo switch della release eseguite `php artisan queue:restart` (o `php artisan horizon:terminate`): i worker completano i job correnti e terminano, e Supervisor li rialza con il nuovo codice. Il segnale di riavvio è conservato nella cache, quindi la cache deve essere condivisa tra tutti i server. La compatibilità del payload dei vecchi job con il nuovo codice è un tema a parte, trattato nell'articolo sul [deploy senza downtime](zero-downtime-deployment-laravel#queues).

---

<a id="monitoring"></a>
## Monitoraggio delle code

Le code si rompono in silenzio: il sito funziona, nessun errore 500, ma le email non partono da tre ore. L'insieme minimo di segnali:

* **Lunghezza della coda e tempo di attesa**: una coda che cresce significa che i worker sono troppo pochi o sono fermi.
* **Tasso di fallimento**: il numero di record in `failed_jobs` in un intervallo di tempo.
* **Vitalità dei worker**: Horizon mostra lo stato dei supervisor; senza Horizon, tenete d'occhio i processi tramite Supervisor.

Il comando integrato `queue:monitor` controlla la dimensione delle code e, al superamento di una soglia, emette l'evento `QueueBusy`, a cui si può agganciare una notifica:

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

Maggiori dettagli su metriche, log e alert nell'articolo sull'[osservabilità](observability-monitoring-laravel).

---

<a id="common-mistakes"></a>
## Errori comuni

**1. `--timeout` maggiore di `retry_after`.**
Un job lungo viene consegnato a un secondo worker mentre il primo lo sta eseguendo. Risultato: duplicati e race condition.

**2. Nessun timeout sul client HTTP.**
Un socket bloccato non sempre viene interrotto dal timeout del job. Il worker è fermo, la coda cresce.

**3. Un solo tentativo di default insieme a `RateLimited` o `WithoutOverlapping`.**
Il primo rilascio in coda fa fallire il job. Aumentate `Tries` e limitate gli errori con `MaxExceptions`.

**4. Job non idempotenti.**
Un retry dopo un timeout, un crash del worker o `queue:retry` invia una seconda email o un secondo addebito.

**5. Dati nel payload invece di identificativi.**
Array di grandi dimensioni e model con relazioni gonfiano Redis e `failed_jobs`. Passate ID e percorsi dei file.

**6. Job dispatchato da una transazione senza `after_commit`.**
Il worker non trova il record non ancora committato o lavora su dati vecchi.

**7. Worker senza limiti di vita.**
La memoria cresce per settimane, finché l'OOM killer non uccide il processo a metà di un job. Usate `--max-jobs`, `--max-time`, `--memory`.

**8. Dimenticare `queue:restart` dopo il deploy.**
I worker eseguono i job con il codice vecchio sul nuovo schema del database.

**9. `queue:retry all` senza analizzare le cause.**
I job falliscono di nuovo, e quelli non idempotenti fanno in tempo a ripetere gli effetti collaterali.

---

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

1. La scala dei timeout è coerente: HTTP/SQL < timeout del job < `retry_after` < `stopwaitsecs` / `stop_grace_period`.
2. L'estensione pcntl è installata; su Redis `block_for` non è `0`.
3. Ogni job ha `Tries`, `Backoff` e, se necessario, `MaxExceptions` o `retryUntil()` impostati consapevolmente.
4. I job sono idempotenti; le chiamate esterne usano chiavi di idempotenza.
5. I job vengono inviati dopo il commit (`after_commit` o `ShouldQueueAfterCommit`).
6. Nel payload ci sono identificativi e percorsi, non grandi quantità di dati; model senza relazioni.
7. Gli import pesanti sono suddivisi in batch, con una coda separata e worker dedicati.
8. I worker vengono riavviati in base a `--max-jobs`, `--max-time`, `--memory` sotto Supervisor o Horizon.
9. `queue:restart` / `horizon:terminate` è un passo obbligatorio del deploy.
10. `queue:prune-failed` e `queue:prune-batches` sono schedulati, e sono configurati alert sulla dimensione delle code e sul tasso di fallimento.

---

## Conclusione

Le code in produzione sono affidabili nella misura in cui i loro numeri sono coerenti e i loro job idempotenti. I timeout si dispongono a scala, i retry si configurano in base al tipo di errore, il lavoro pesante si spezza in piccole parti ripetibili, e i worker vivono per un tempo limitato e vengono riavviati a ogni deploy. Allora `failed_jobs` smette di essere un cimitero e diventa una lista di lavoro da analizzare e ripetere.

---

<a id="self-test-quiz"></a>
## Quiz di autoverifica

### Domanda 1: Il `retry_after` della connessione è di 90 secondi, il worker è avviato con `--timeout=300`. Il job dura 200 secondi. Cosa succede?
- A) Il job terminerà tranquillamente: il worker ha margine sul timeout.
- B) Dopo 90 secondi la connessione consegnerà il job a un altro worker, che inizierà a eseguirlo una seconda volta in parallelo al primo.
- C) Il worker interromperà il job dopo 90 secondi.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
`retry_after` viene conteggiato dal broker, che non sa se il worker è vivo. Dopo 90 secondi il job viene considerato abbandonato e consegnato di nuovo. Per questo `--timeout` deve essere di qualche secondo inferiore a `retry_after`.
</details>

### Domanda 2: Un job usa il middleware `RateLimited` e finisce in `failed_jobs` senza aver mai eseguito `handle()`. Perché?
- A) `RateLimited` non funziona con il driver Redis.
- B) Il rilascio del job in coda a causa del limite consuma un tentativo, e per impostazione predefinita il job ha un solo tentativo.
- C) Il middleware viene eseguito dopo `handle()`.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Ogni `release()` è un tentativo consumato. Per i job con rate limiting si aumenta `Tries` (o si usa `retryUntil()`) e si limita il numero di errori reali con `MaxExceptions`.
</details>

### Domanda 3: Qual è il modo corretto di importare un file da 400 000 righe tramite la coda?
- A) Un unico job con `#[Timeout(7200)]` e `--memory=4096` sul worker.
- B) Un batch di piccoli job idempotenti, ciascuno dei quali elabora il proprio intervallo di righe, in una coda separata con worker dedicati.
- C) Passare tutte le righe del file al costruttore del job, così che il worker non debba leggere il file.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Le parti piccole rientrano nei timeout e nei limiti di memoria, si ripetono in modo indipendente e mostrano il progresso. L'opzione A perde tutto il lavoro se cade all'ultima riga, mentre l'opzione C gonfia il payload, la memoria di Redis e `failed_jobs`.
</details>