---
title: 'Colas de Laravel en producción: timeouts y failed_jobs | DevSense'
description: 'Cómo configurar las colas de Laravel para producción: retry_after y --timeout, reintentos y backoff, failed_jobs, memoria de los workers de larga duración, importaciones pesadas con batches, jobs únicos, Supervisor y Horizon.'
faq:
    - { question: '¿En qué se diferencia retry_after de --timeout en las colas de Laravel?', answer: 'retry_after se define en config/queue.php e indica a la conexión al cabo de cuántos segundos debe considerar colgado un job ya reservado y entregarlo de nuevo. --timeout (o el atributo #[Timeout] del job) limita cuántos segundos deja el worker que se ejecute un job antes de terminar con error. El timeout debe ser unos segundos menor que retry_after; de lo contrario, el job puede ejecutarse dos veces.' }
    - { question: '¿Por qué un job de la cola se ejecutó dos veces?', answer: 'Lo más habitual es que el job tardara más que retry_after y la conexión se lo entregara a un segundo worker, o que el worker muriera a mitad de la ejecución (despliegue, OOM, SIGKILL) y el job volviera a la cola. Por eso los jobs deben ser idempotentes y los timeouts deben estar coordinados: cliente HTTP < timeout del job < retry_after.' }
    - { question: '¿Por qué crece la memoria de un worker de cola?', answer: 'El worker de queue:work es un proceso de larga duración que no recarga el framework entre jobs. La memoria se acumula en arrays estáticos y singletons con estado, en el log de consultas, en Telescope, en imágenes GD y en colecciones grandes. Limite la vida del worker con los flags --max-jobs, --max-time y --memory y ejecútelo bajo Supervisor, que volverá a levantar el proceso.' }
    - { question: '¿Qué hacer con los registros de la tabla failed_jobs?', answer: 'No relanzarlos a ciegas. Primero hay que revisar la excepción (queue:failed, Horizon), corregir la causa y después reintentar los jobs con el comando queue:retry. Para las notificaciones, use el método failed() del job o el evento Queue::failing, y elimine los registros antiguos de forma periódica con queue:prune-failed en el scheduler.' }
published: '2026-10-03'
---
# Colas de Laravel en producción: timeouts, failed_jobs, memoria y jobs pesados

En local, las colas «simplemente funcionan»: `queue:work` en la terminal de al lado, los jobs se ejecutan en un segundo y no hay errores. En producción empiezan historias de otro género. Un cliente ha recibido dos veces el mismo correo con la factura. La importación de una lista de precios de 400 mil filas lleva tres horas dando vueltas y se ha comido dos gigabytes de memoria. En `failed_jobs` se han acumulado veinte mil registros y nadie sabe cuáles importan. Tras un despliegue, los workers se pasan una semana ejecutando jobs con el código antiguo.

Este artículo trata de cómo configurar las colas de Laravel para que sobrevivan a fallos, despliegues y grandes volúmenes: cómo se relacionan `retry_after` y `--timeout`, cómo funcionan los reintentos, qué hacer con `failed_jobs`, por qué crece la memoria de los workers y cómo trocear las importaciones pesadas. La ejecución local de colas en Docker se explica en el artículo [Sail: colas y workers](../tools/sail-queues), y la elección de broker, en la [comparativa de colas de mensajes](message-queues-compared).

**Guías relacionadas:** [Integraciones resilientes](resilient-external-integrations) · [Zero-downtime deployment](zero-downtime-deployment-laravel) · [Eloquent y grandes volúmenes de datos](eloquent-large-datasets) · [Observabilidad y monitorización](observability-monitoring-laravel)

## Contenido

* [Ciclo de vida de un job: intentos, devolución, fallo](#lifecycle)
* [retry_after y --timeout: la escalera de timeouts](#timeouts)
* [Reintentos: tries, backoff, retryUntil](#retries)
* [failed_jobs: análisis, reintento, limpieza](#failed-jobs)
* [Memoria de los workers de larga duración](#memory)
* [Importaciones y exportaciones pesadas](#heavy-jobs)
* [Duplicados y carreras: jobs únicos y after_commit](#uniqueness)
* [Colas separadas y prioridades](#priorities)
* [Supervisor y Horizon](#supervisor)
* [Monitorización de colas](#monitoring)
* [Errores frecuentes](#common-mistakes)
* [Checklist](#checklist)
* [Quiz de autoevaluación](#self-test-quiz)

---

<a id="lifecycle"></a>
## Ciclo de vida de un job: intentos, devolución, fallo

Cuando un worker toma un job, empieza un **intento** (attempt). El intento se consume aunque el método `handle()` no llegue a ejecutarse hasta el final. Según la documentación de Laravel, «se comen» un intento:

* una excepción no controlada en el job;
* la devolución manual a la cola con `$this->release()`;
* middleware como `WithoutOverlapping` o `RateLimited` que no obtuvieron el bloqueo y devolvieron el job;
* superar el timeout;
* la ejecución correcta de `handle()`.

**Por defecto, Laravel hace un solo intento.** Si el job falla, se considera fallido de inmediato y va a `failed_jobs`. Si usa `RateLimited` o `WithoutOverlapping`, un único intento casi seguro que no basta: el job «se quemará» en la primera devolución sin haber llegado a empezar su trabajo.

```
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 y --timeout: la escalera de timeouts

Son dos mecanismos distintos que es fácil confundir.

* **`retry_after`** es un parámetro de la conexión en `config/queue.php`. Responde a la pregunta «al cabo de cuántos segundos hay que considerar abandonado un job reservado y entregárselo a otro worker». El broker no sabe si el worker sigue vivo, así que solo se guía por el tiempo. En Amazon SQS, su equivalente es el Visibility Timeout de la configuración de la cola.
* **`--timeout`** de `queue:work` (o el atributo `#[Timeout]` del job) indica cuántos segundos deja el worker que se ejecute un job. Por defecto, 60 segundos. Si se agota el tiempo, el proceso del worker termina con error y Supervisor levanta uno nuevo. Los timeouts requieren la extensión **pcntl**.

Si `--timeout` es mayor que `retry_after`, un job que tarde más que `retry_after` **se entregará a un segundo worker mientras el primero todavía lo está ejecutando**. De ahí los correos duplicados, los cobros dobles y las carreras. La documentación formula la regla sin rodeos: `--timeout` debe ser al menos unos segundos más corto que `retry_after`.

En la práctica hay que coordinar toda una escalera de valores, cada uno mayor que el anterior:

| Nivel | Dónde se define | Ejemplo |
|---------|--------------|--------|
| Timeouts del cliente HTTP y de SQL | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 s |
| Timeout del job | `#[Timeout(120)]` o `--timeout=120` | 120 s |
| `retry_after` de la conexión | `config/queue.php` | 150 s |
| Espera a la parada del worker | `stopwaitsecs` en Supervisor, `stop_grace_period` en 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]
> **El timeout del job no interrumpe un socket colgado.** La documentación lo advierte expresamente: la E/S bloqueante (sockets, conexiones HTTP salientes) puede no responder al timeout del worker. Defina siempre timeouts propios para el cliente HTTP y para las consultas a la base de datos.

`block_for` en Redis indica al driver cuántos segundos esperar un nuevo job en modo bloqueante. El valor `0` bloquea el worker indefinidamente, y deja de procesar señales como `SIGTERM` hasta que llega el siguiente job, lo que rompe la parada ordenada durante el despliegue.

Si un job no se puede repetir tras un timeout (por ejemplo, porque ya podría haber enviado datos a un sistema externo), márquelo con el atributo `#[FailOnTimeout]`: así, el timeout pasa el job directamente a fallidos, sin reintentos.

---

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

En Laravel 13, los parámetros de reintento se definen cómodamente con atributos PHP directamente en la clase del job (en versiones anteriores, con las propiedades `$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`**: número máximo de intentos. El valor de la clase tiene prioridad sobre el flag `--tries` del worker.
* **`MaxExceptions`**: tras cuántas excepciones reales falla el job, aunque todavía le queden intentos. Permite admitir muchas devoluciones por rate limit sin machacar diez veces una API rota.
* **`Backoff`**: la pausa antes de reintentar tras una excepción. El array `[10, 60, 300]` define pausas crecientes: 10 segundos, un minuto y cinco minutos para el tercer reintento y los siguientes.
* **`retryUntil()`**: alternativa al número de intentos: reintentar tantas veces como haga falta, pero no más allá del momento indicado. Si se definen tanto `tries` como `retryUntil()`, tiene prioridad `retryUntil()`.

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

Para APIs externas inestables resulta útil el middleware `ThrottlesExceptions`: tras una serie de errores, aplaza los jobs durante el tiempo indicado en lugar de quemar intentos contra un servicio que se sabe caído. Es una implementación del patrón Circuit Breaker a nivel de cola (en detalle en el artículo sobre [integraciones resilientes](resilient-external-integrations)).

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

> [!NOTE]
> **Un reintento solo es seguro si el job es idempotente.** Un job puede volver a ejecutarse tras un timeout, la caída de un worker o su propio `queue:retry`. Use `upsert` en lugar de `insert`, claves de idempotencia en las APIs de pago y una comprobación de «¿ya está hecho?» al principio de `handle()`.

---

<a id="failed-jobs"></a>
## failed_jobs: análisis, reintento, limpieza

Cuando se agotan los intentos, Laravel llama al método `failed()` del job y lo registra en la tabla `failed_jobs` junto con el payload y el texto de la excepción.

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

El flujo de trabajo con los jobs fallidos:

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

Reglas que ahorran disgustos:

* **No ejecute `queue:retry all` a ciegas.** Si la causa no se ha corregido, los jobs volverán a fallar, y los no idempotentes llegarán a repetir parte de su trabajo.
* **Limpie la tabla con el scheduler.** Por defecto, `queue:prune-failed` elimina los registros de más de 24 horas; ajuste `--hours` a su proceso de revisión.
* **Elimine los jobs cuyos modelos se han borrado.** Si el modelo se eliminó mientras el job esperaba en la cola, el atributo `#[DeleteWhenMissingModels]` borrará el job sin ruido en lugar de fallar con `ModelNotFoundException`.
* **Alerte por la tendencia, no por cada error.** Un job fallido por hora es ruido; cien por minuto, un incidente. El evento `Queue::failing()` es cómodo para alimentar un contador de métricas.

```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 de los workers de larga duración

`queue:work` es un demonio: carga la aplicación una sola vez y ejecuta los jobs en bucle sin reiniciar el framework. Es rápido, pero todo lo que un job deja en memoria se queda allí hasta el final de la vida del proceso. Fuentes típicas de crecimiento:

* arrays estáticos y singletons que acumulan estado (una «caché» en una propiedad de un servicio);
* el log de consultas (`DB::enableQueryLog()`), Telescope y Debugbar en producción;
* recursos de GD/Imagick sin `imagedestroy()` ni `clear()`;
* colecciones grandes cargadas enteras con `get()` en lugar de `lazyById()`.

La protección consiste en limitar la vida del worker y dejar que el gestor de procesos lo reinicie:

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

* `--max-jobs`: terminar tras N jobs;
* `--max-time`: terminar tras N segundos de funcionamiento;
* `--memory`: terminar si, tras un job, el proceso ocupa más de N megabytes (por defecto, 128).

Es importante entender el orden: `--memory` se comprueba **entre** jobs. Si un solo job supera por sí mismo el `memory_limit` de PHP, el proceso caerá con un error fatal a mitad de la ejecución, el job volverá a la cola solo después de `retry_after` y volverá a caer. Estos jobs no se «curan» con límites: hay que reescribirlos con procesamiento en streaming y troceando el trabajo.

---

<a id="heavy-jobs"></a>
## Importaciones y exportaciones pesadas

Un único job «importar un archivo de 400 000 filas» lo reúne todo: ejecución larga, riesgo de timeout, crecimiento de la memoria y progreso nulo si falla en la fila 399 999. El esquema que funciona es dividir el trabajo en muchos jobs pequeños e idempotentes y agruparlos en 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']);
    }
}
```

Lo importante aquí:

* **Al job se le pasan las coordenadas del trabajo, no los datos.** La ruta del archivo y el rango de filas pesan bytes. Un array de dos mil filas en el constructor inflará el payload, la memoria de Redis y la tabla `failed_jobs`.
* **Cada parte es idempotente.** Un `upsert` por `sku` da el mismo resultado al repetirse.
* **El progreso es visible.** `$batch->progress()`, `processedJobs()` y `failedJobs` se pueden mostrar al usuario. Los batches necesitan la tabla `job_batches` (`php artisan make:queue-batches-table`), que también hay que limpiar con el comando `queue:prune-batches`.
* **`allowFailures()`** evita que una parte corrupta cancele toda la importación; sin él, el primer error cancela el batch.
* **Una cola `imports` separada**, con sus propios workers, impide que la importación retrase correos y notificaciones.

La misma lógica vale para las exportaciones: el job lee los datos en streaming con `lazyById()` (véase [Eloquent y grandes volúmenes de datos](eloquent-large-datasets#export)), escribe el archivo en el almacenamiento y envía un enlace al usuario.

Si un job recibe un modelo, Laravel lo serializa junto con las relaciones cargadas. El atributo `#[WithoutRelations]` (o `$model->withoutRelations()`) deja en el payload solo el identificador: el modelo se volverá a cargar desde la base de datos al ejecutarse.

---

<a id="uniqueness"></a>
## Duplicados y carreras: jobs únicos y after_commit

**El job arranca antes de que se confirmen los datos.** Un bug clásico: el job se despacha dentro de una transacción, el worker lo toma al instante y busca el pedido por ID, pero la transacción todavía no se ha confirmado. `ModelNotFoundException` o, peor aún, el job trabaja con datos antiguos. Soluciones:

* `'after_commit' => true` en la configuración de la conexión: todos los jobs, eventos encolados, correos y notificaciones esperan al commit;
* la interfaz `ShouldQueueAfterCommit` en un job concreto;
* `->afterCommit()` al despachar.

**El mismo job varias veces en la cola.** El usuario ha pulsado tres veces «Recalcular» y en la cola hay tres jobs pesados idénticos. La interfaz `ShouldBeUnique` impide encolar el segundo mientras el primero no se haya ejecutado:

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

La unicidad se basa en un bloqueo atómico en la caché (Redis, base de datos, memcached). El bloqueo se libera tras la ejecución o el fallo definitivo del job; `ShouldBeUniqueUntilProcessing` lo libera antes de empezar la ejecución, lo que permite encolar el siguiente job mientras el actual está en marcha. Dentro de los batches, la unicidad no se aplica.

**Dos jobs distintos modifican los mismos datos a la vez.** Aquí ayuda el middleware `WithoutOverlapping` con una clave por entidad: los jobs con la misma clave se ejecutan de uno en uno.

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

En la práctica, `expireAfter` es obligatorio: si el worker muere a mitad de un job, un bloqueo sin tiempo de vida se quedará para siempre.

---

<a id="priorities"></a>
## Colas separadas y prioridades

Una sola cola `default` para todo es una causa frecuente de quejas del tipo «el correo para restablecer la contraseña llegó a los veinte minutos»: delante había diez mil jobs de importación. Separe los jobs según el tipo de carga:

* `high`: lo que el usuario está esperando: correos de restablecimiento de contraseña, notificaciones, webhooks de pago;
* `default`: jobs en segundo plano normales;
* `imports` / `reports`: pesados y largos, con sus propios timeouts y límites de memoria.

El worker procesa las colas por prioridad de izquierda a derecha: `--queue=high,default`. Para las colas pesadas, lance workers separados con otros `--timeout` y `--memory` y, por tanto, también una conexión aparte con un `retry_after` adecuado.

En Laravel 13, el enrutamiento de jobs a colas se puede reunir en un solo lugar en vez de usar `->onQueue()` en cada llamada:

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

Los workers deben funcionar de forma continua y volver a levantarse tras una caída, `--max-time` o `queue:restart`. Sin un gestor de procesos, eso no ocurre.

```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` debe ser mayor que el job más largo de ese grupo: al detenerse, Supervisor envía `SIGTERM`, el worker termina el job actual y solo cuando vence `stopwaitsecs` el proceso se mata con `SIGKILL`. En Docker, ese papel lo cumple `stop_grace_period`.

**Horizon** es más cómodo para colas sobre Redis: la configuración de los workers vive en el código, hay autobalanceo de procesos entre colas y un dashboard con métricas, jobs fallidos y etiquetas.

```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 regla de los timeouts también se aplica aquí: el `timeout` del supervisor de Horizon debe ser unos segundos menor que el `retry_after` de la conexión y, a la vez, mayor que el timeout de cualquier job concreto. Las métricas de Horizon se construyen a partir de snapshots: añada `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Despliegue.** Los workers mantienen el código en memoria y no ven los cambios sin un reinicio. Tras cambiar de release, ejecute `php artisan queue:restart` (o `php artisan horizon:terminate`): los workers terminarán los jobs en curso y saldrán, y Supervisor los levantará con el código nuevo. La señal de reinicio se guarda en la caché, por lo que la caché debe ser compartida por todos los servidores. La compatibilidad del payload de los jobs antiguos con el código nuevo es un tema aparte, tratado en el artículo sobre [despliegue sin downtime](zero-downtime-deployment-laravel#queues).

---

<a id="monitoring"></a>
## Monitorización de colas

Las colas se rompen en silencio: la web funciona, no hay errores 500 y los correos llevan tres horas sin salir. El conjunto mínimo de señales:

* **Longitud de la cola y tiempo de espera**: una cola que crece significa que hay pocos workers o que están parados.
* **Tasa de fallos**: el número de registros en `failed_jobs` por intervalo.
* **Salud de los workers**: Horizon muestra el estado de los supervisores; sin Horizon, vigile los procesos a través de Supervisor.

El comando integrado `queue:monitor` comprueba el tamaño de las colas y, al superarse el umbral, lanza el evento `QueueBusy`, al que se le puede enganchar una notificación:

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

Más detalles sobre métricas, logs y alertas en el artículo sobre [observabilidad](observability-monitoring-laravel).

---

<a id="common-mistakes"></a>
## Errores frecuentes

**1. `--timeout` mayor que `retry_after`.**
Un job largo se entrega a un segundo worker mientras el primero lo está ejecutando. Resultado: duplicados y carreras.

**2. Sin timeout en el cliente HTTP.**
Un socket colgado no siempre lo interrumpe el timeout del job. El worker se queda parado y la cola crece.

**3. Un solo intento por defecto junto con `RateLimited` o `WithoutOverlapping`.**
La primera devolución a la cola hace fallar el job. Aumente `Tries` y limite los errores con `MaxExceptions`.

**4. Jobs no idempotentes.**
Un reintento tras un timeout, la caída de un worker o `queue:retry` envía un segundo correo o hace un segundo cobro.

**5. Datos en el payload en lugar de identificadores.**
Los arrays grandes y los modelos con relaciones inflan Redis y `failed_jobs`. Pase IDs y rutas de archivo.

**6. Un job despachado desde una transacción sin `after_commit`.**
El worker no encuentra el registro todavía sin confirmar o trabaja con datos antiguos.

**7. Workers sin límite de vida.**
La memoria crece durante semanas hasta que el OOM killer mata el proceso a mitad de un job. Use `--max-jobs`, `--max-time`, `--memory`.

**8. Olvidar `queue:restart` tras el despliegue.**
Los workers ejecutan jobs con el código antiguo contra el esquema nuevo de la base de datos.

**9. `queue:retry all` sin analizar las causas.**
Los jobs vuelven a fallar, y los no idempotentes llegan a repetir sus efectos secundarios.

---

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

1. La escalera de timeouts está coordinada: HTTP/SQL < timeout del job < `retry_after` < `stopwaitsecs` / `stop_grace_period`.
2. La extensión pcntl está instalada; en Redis, `block_for` no es `0`.
3. Cada job tiene definidos de forma consciente `Tries`, `Backoff` y, si hace falta, `MaxExceptions` o `retryUntil()`.
4. Los jobs son idempotentes; las llamadas externas usan claves de idempotencia.
5. Los jobs se despachan después del commit (`after_commit` o `ShouldQueueAfterCommit`).
6. En el payload van identificadores y rutas, no grandes volúmenes de datos; los modelos, sin relaciones.
7. Las importaciones pesadas se dividen en batches con una cola separada y sus propios workers.
8. Los workers se reinician por `--max-jobs`, `--max-time`, `--memory` bajo Supervisor o Horizon.
9. `queue:restart` / `horizon:terminate` es un paso obligatorio del despliegue.
10. `queue:prune-failed` y `queue:prune-batches` están en el scheduler, y hay alertas configuradas sobre el tamaño de las colas y la tasa de fallos.

---

## Resumen

Las colas en producción son tan fiables como coordinados estén sus valores e idempotentes sus jobs. Los timeouts se escalonan, los reintentos se ajustan al tipo de error, el trabajo pesado se trocea en partes pequeñas y repetibles, y los workers viven un tiempo limitado y se reinician en cada despliegue. Así, `failed_jobs` deja de ser un cementerio y se convierte en una lista de trabajo que se puede revisar y reintentar.

---

<a id="self-test-quiz"></a>
## Cuestionario de autoevaluación

### Pregunta 1: El `retry_after` de la conexión es de 90 segundos y el worker se ha lanzado con `--timeout=300`. Un job tarda 200 segundos. ¿Qué ocurrirá?
- A) El job terminará sin problemas: el worker tiene margen de timeout.
- B) A los 90 segundos, la conexión entregará el job a otro worker, y empezará a ejecutarse una segunda vez en paralelo con la primera.
- C) El worker interrumpirá el job a los 90 segundos.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
`retry_after` lo cuenta el broker, que no sabe si el worker sigue vivo. A los 90 segundos, el job se considera abandonado y se entrega de nuevo. Por eso `--timeout` debe ser unos segundos menor que `retry_after`.
</details>

### Pregunta 2: Un job usa el middleware `RateLimited` y acaba en `failed_jobs` sin haber ejecutado ni una sola vez `handle()`. ¿Por qué?
- A) `RateLimited` no funciona con el driver de Redis.
- B) Devolver el job a la cola por el límite consume un intento, y por defecto un job solo tiene un intento.
- C) El middleware se ejecuta después de `handle()`.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Cada `release()` es un intento consumido. Para los jobs con rate limiting se aumenta `Tries` (o se usa `retryUntil()`), y el número de errores reales se limita con `MaxExceptions`.
</details>

### Pregunta 3: ¿Cuál es la forma correcta de importar un archivo de 400 000 filas a través de la cola?
- A) Un único job con `#[Timeout(7200)]` y `--memory=4096` en el worker.
- B) Un batch de jobs pequeños e idempotentes, cada uno procesando su propio rango de filas, en una cola separada con sus propios workers.
- C) Pasar todas las filas del archivo al constructor del job para que el worker no tenga que leer el archivo.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Las partes pequeñas caben en los timeouts y en los límites de memoria, se reintentan de forma independiente y muestran el progreso. La opción A pierde todo el trabajo si falla en la última fila, y la opción C infla el payload, la memoria de Redis y `failed_jobs`.
</details>