---
title: 'Черги Laravel у продакшені: таймаути й failed_jobs | DevSense'
description: "Як налаштувати черги Laravel для продакшену: retry_after і --timeout, повтори та backoff, failed_jobs, пам'ять довгоживучих воркерів, важкі імпорти через батчі, унікальні задачі, Supervisor і Horizon."
faq:
    - { question: 'Чим retry_after відрізняється від --timeout у чергах Laravel?', answer: "retry_after задається в config/queue.php і каже з'єднанню, через скільки секунд вважати взяту задачу завислою й видати її знову. --timeout (або атрибут #[Timeout] у задачі) обмежує, скільки секунд воркер дає задачі на виконання, перш ніж завершитися з помилкою. Таймаут має бути на кілька секунд меншим за retry_after, інакше задача може виконатися двічі." }
    - { question: 'Чому задача в черзі виконалася двічі?', answer: "Найчастіше через те, що задача працювала довше за retry_after і з'єднання видало її другому воркеру, або воркер убили посеред виконання (деплой, OOM, SIGKILL), і задача повернулася в чергу. Тому задачі мають бути ідемпотентними, а таймаути — узгодженими: HTTP-клієнт < таймаут задачі < retry_after." }
    - { question: "Чому зростає пам'ять воркера черги?", answer: "Воркер queue:work — довгоживучий процес, який не перезавантажує фреймворк між задачами. Пам'ять накопичують статичні масиви й синглтони зі станом, лог запитів, Telescope, зображення GD і великі колекції. Обмежте життя воркера прапорцями --max-jobs, --max-time і --memory та запускайте його під Supervisor, який підніме процес заново." }
    - { question: 'Що робити із записами в таблиці failed_jobs?', answer: 'Не перезапускати наосліп. Спершу подивитися виняток (queue:failed, Horizon), виправити причину, потім повторити задачі командою queue:retry. Для сповіщень використовувати метод failed() у задачі або подію Queue::failing, а старі записи регулярно видаляти командою queue:prune-failed за розкладом.' }
published: '2026-10-03'
---
# Черги Laravel у продакшені: таймаути, failed_jobs, пам'ять і важкі задачі

Локально черги «просто працюють»: `queue:work` у сусідньому терміналі, задачі виконуються за секунду, помилок немає. У продакшені починаються історії іншого жанру. Клієнтові двічі пішов той самий лист із рахунком. Імпорт прайс-листа на 400 тисяч рядків крутиться третю годину й з'їв два гігабайти пам'яті. У `failed_jobs` накопичилося двадцять тисяч записів, і ніхто не знає, які з них важливі. Воркери після деплою тиждень виконують задачі старим кодом.

Ця стаття — про налаштування черг Laravel так, щоб вони переживали збої, деплої та великі обсяги: як пов'язані `retry_after` і `--timeout`, як влаштовані повтори, що робити з `failed_jobs`, чому зростає пам'ять воркерів і як дробити важкі імпорти. Локальний запуск черг у Docker розібрано в статті [Sail: черги й воркери](../tools/sail-queues), а вибір брокера — в [порівнянні черг повідомлень](message-queues-compared).

**Пов'язані матеріали:** [Відмовостійкі інтеграції](resilient-external-integrations) · [Zero-downtime deployment](zero-downtime-deployment-laravel) · [Eloquent і великі дані](eloquent-large-datasets) · [Спостережуваність і моніторинг](observability-monitoring-laravel)

## Зміст

* [Життєвий цикл задачі: спроби, повернення, провал](#lifecycle)
* [retry_after і --timeout: драбина таймаутів](#timeouts)
* [Повтори: tries, backoff, retryUntil](#retries)
* [failed_jobs: розбір, повтор, очищення](#failed-jobs)
* [Пам'ять довгоживучих воркерів](#memory)
* [Важкі імпорти й експорти](#heavy-jobs)
* [Дублікати й гонки: унікальні задачі та after_commit](#uniqueness)
* [Окремі черги та пріоритети](#priorities)
* [Supervisor і Horizon](#supervisor)
* [Моніторинг черг](#monitoring)
* [Часті помилки](#common-mistakes)
* [Чеклист](#checklist)
* [Квіз для самоперевірки](#self-test-quiz)

---

<a id="lifecycle"></a>
## Життєвий цикл задачі: спроби, повернення, провал

Коли воркер бере задачу, починається **спроба** (attempt). Спроба витрачається, навіть якщо метод `handle()` не виконався до кінця. Згідно з документацією Laravel, спробу «з'їдають»:

* необроблений виняток у задачі;
* ручне повернення в чергу через `$this->release()`;
* middleware на кшталт `WithoutOverlapping` або `RateLimited`, які не отримали блокування й повернули задачу;
* перевищення таймауту;
* успішне виконання `handle()`.

**За замовчуванням Laravel робить одну спробу.** Якщо задача впала — вона одразу вважається проваленою й потрапляє в `failed_jobs`. Якщо ви використовуєте `RateLimited` або `WithoutOverlapping`, однієї спроби майже напевно замало: задача «згорить» на першому ж поверненні, так і не почавши роботу.

```
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 і --timeout: драбина таймаутів

Це два різні механізми, які легко сплутати.

* **`retry_after`** — параметр з'єднання в `config/queue.php`. Він відповідає на питання «через скільки секунд вважати взяту задачу покинутою й видати її іншому воркеру». Брокер не знає, чи живий воркер, тому орієнтується лише на час. В Amazon SQS замість нього — Visibility Timeout у налаштуваннях черги.
* **`--timeout`** у `queue:work` (або атрибут `#[Timeout]` у задачі) — скільки секунд воркер дає задачі на виконання. За замовчуванням — 60 секунд. Якщо час вийшов, процес воркера завершується з помилкою, а Supervisor піднімає новий. Для таймаутів потрібне розширення **pcntl**.

Якщо `--timeout` більший за `retry_after`, задачу, яка працює довше за `retry_after`, буде **видано другому воркеру, поки перший ще її виконує**. Звідси дублікати листів, подвійні списання й гонки. Документація формулює правило прямо: `--timeout` має бути щонайменше на кілька секунд коротшим за `retry_after`.

На практиці узгоджувати потрібно цілу драбину значень — кожне наступне більше за попереднє:

| Рівень | Де задається | Приклад |
|---------|--------------|--------|
| Таймаути HTTP-клієнта й SQL | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 с |
| Таймаут задачі | `#[Timeout(120)]` або `--timeout=120` | 120 с |
| `retry_after` з'єднання | `config/queue.php` | 150 с |
| Очікування зупинки воркера | `stopwaitsecs` у Supervisor, `stop_grace_period` у Docker | 180 с |

```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]
> **Таймаут задачі не перериває завислий сокет.** Документація окремо попереджає: блокувальне введення-виведення (сокети, вихідні HTTP-з'єднання) може не реагувати на таймаут воркера. Завжди задавайте власні таймаути HTTP-клієнту й запитам до бази.

`block_for` у Redis каже драйверу, скільки секунд чекати на нову задачу в блокувальному режимі. Значення `0` блокує воркер нескінченно — і він перестає обробляти сигнали на кшталт `SIGTERM` до надходження наступної задачі, що ламає м'яку зупинку під час деплою.

Якщо задачу не можна повторювати після таймауту (наприклад, вона вже могла надіслати дані в зовнішню систему), позначте її атрибутом `#[FailOnTimeout]`: тоді таймаут одразу переводить задачу в провалені, без повторів.

---

<a id="retries"></a>
## Повтори: tries, backoff, retryUntil

У Laravel 13 параметри повторів зручно задавати PHP-атрибутами прямо на класі задачі (у старіших версіях — властивостями `$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`** — максимум спроб. Значення на класі має пріоритет над прапорцем `--tries` воркера.
* **`MaxExceptions`** — після скількох справжніх винятків задача провалюється, навіть якщо спроби ще є. Це дає змогу дозволити багато повернень через rate limit, але не гатити по зламаному API десять разів.
* **`Backoff`** — пауза перед повтором після винятку. Масив `[10, 60, 300]` задає наростаючі паузи: 10 секунд, хвилина, п'ять хвилин для третього й наступних повторів.
* **`retryUntil()`** — альтернатива кількості спроб: повторювати скільки завгодно разів, але не довше за вказаний момент. Якщо задано і `tries`, і `retryUntil()`, пріоритет має `retryUntil()`.

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

Для нестабільних зовнішніх API корисний middleware `ThrottlesExceptions`: після серії помилок він відкладає задачі на заданий час, замість того щоб спалювати спроби на сервісі, який завідомо лежить. Це реалізація патерну Circuit Breaker на рівні черги (докладно — у статті про [відмовостійкі інтеграції](resilient-external-integrations)).

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

> [!NOTE]
> **Повтор безпечний, лише якщо задача ідемпотентна.** Задача може виконатися повторно після таймауту, падіння воркера або вашого `queue:retry`. Використовуйте `upsert` замість `insert`, ключі ідемпотентності в платіжних API та перевірку «вже зроблено?» на початку `handle()`.

---

<a id="failed-jobs"></a>
## failed_jobs: розбір, повтор, очищення

Коли спроби закінчилися, Laravel викликає метод `failed()` задачі й записує її в таблицю `failed_jobs` разом із payload і текстом винятку.

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

Робочий цикл із проваленими задачами:

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

Правила, які бережуть нерви:

* **Не робіть `queue:retry all` наосліп.** Якщо причину не виправлено, задачі впадуть знову, а неідемпотентні — встигнуть щось зробити повторно.
* **Очищайте таблицю за розкладом.** За замовчуванням `queue:prune-failed` видаляє записи, старші за 24 години; вкажіть `--hours` під свій процес розбору.
* **Видаляйте задачі з видаленими моделями.** Якщо модель видалили, поки задача чекала в черзі, атрибут `#[DeleteWhenMissingModels]` тихо видалить задачу замість падіння з `ModelNotFoundException`.
* **Алертьте не на кожну помилку, а на тренд.** Одна провалена задача на годину — шум, сто за хвилину — інцидент. Подія `Queue::failing()` зручна для лічильника метрик.

```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>
## Пам'ять довгоживучих воркерів

`queue:work` — це демон: він завантажує застосунок один раз і виконує задачі в циклі, не перезапускаючи фреймворк. Це швидко, але все, що задача залишила в пам'яті, лишається там до кінця життя процесу. Типові джерела зростання:

* статичні масиви й синглтони, які накопичують стан («кеш» у властивості сервісу);
* лог запитів (`DB::enableQueryLog()`), Telescope і Debugbar у продакшені;
* ресурси GD/Imagick без `imagedestroy()` або `clear()`;
* великі колекції, завантажені повністю через `get()` замість `lazyById()`.

Захист — обмежувати життя воркера й дозволити менеджеру процесів перезапускати його:

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

* `--max-jobs` — завершитися після N задач;
* `--max-time` — завершитися через N секунд роботи;
* `--memory` — завершитися, якщо після задачі процес займає більше ніж N мегабайтів (за замовчуванням 128).

Важливо розуміти порядок: `--memory` перевіряється **між** задачами. Якщо одна задача сама по собі перевищує `memory_limit` PHP, процес упаде з фатальною помилкою посеред виконання, задача повернеться в чергу лише після `retry_after` — і знову впаде. Такі задачі потрібно не «лікувати» лімітами, а переписувати: потокова обробка й дроблення на частини.

---

<a id="heavy-jobs"></a>
## Важкі імпорти й експорти

Одна задача «імпортувати файл на 400 000 рядків» — це все одразу: довге виконання, ризик таймауту, зростання пам'яті й нульовий прогрес у разі падіння на 399 999-му рядку. Робоча схема — розбити роботу на багато маленьких ідемпотентних задач і об'єднати їх у **батч**.

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

Що тут важливо:

* **У задачу передаються координати роботи, а не дані.** Шлях до файлу й діапазон рядків важать байти. Масив із двох тисяч рядків у конструкторі роздує payload, пам'ять Redis і таблицю `failed_jobs`.
* **Кожна частина ідемпотентна.** `upsert` за `sku` дає той самий результат під час повтору.
* **Прогрес видно.** `$batch->progress()`, `processedJobs()` і `failedJobs` можна показати користувачеві. Для батчів потрібна таблиця `job_batches` (`php artisan make:queue-batches-table`), і її теж потрібно очищати командою `queue:prune-batches`.
* **`allowFailures()`** не скасовує весь імпорт через одну биту частину; без нього перша помилка скасовує батч.
* **Окрема черга `imports`** зі своїми воркерами не дає імпорту затримати листи й сповіщення.

Та сама логіка для експорту: задача читає дані потоком через `lazyById()` (див. [Eloquent і великі дані](eloquent-large-datasets#export)), пише файл у сховище й надсилає користувачеві посилання.

Якщо задача приймає модель, Laravel серіалізує її разом із завантаженими зв'язками. Атрибут `#[WithoutRelations]` (або `$model->withoutRelations()`) залишає в payload лише ідентифікатор — модель буде заново завантажено з бази під час виконання.

---

<a id="uniqueness"></a>
## Дублікати й гонки: унікальні задачі та after_commit

**Задача стартує раніше, ніж дані закомічено.** Класичний баг: задача надсилається всередині транзакції, воркер бере її миттєво, шукає замовлення за ID — а транзакцію ще не закомічено. `ModelNotFoundException`, або гірше — задача працює зі старими даними. Рішення:

* `'after_commit' => true` у налаштуваннях з'єднання — усі задачі, події в черзі, листи й сповіщення чекають на коміт;
* інтерфейс `ShouldQueueAfterCommit` на конкретній задачі;
* `->afterCommit()` під час надсилання.

**Та сама задача в черзі кілька разів.** Користувач тричі натиснув «Перерахувати», і в черзі три однакові важкі задачі. Інтерфейс `ShouldBeUnique` не дасть поставити другу, доки першу не виконано:

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

Унікальність тримається на атомарному блокуванні в кеші (Redis, база, memcached). Блокування знімається після виконання або остаточного провалу задачі; `ShouldBeUniqueUntilProcessing` знімає його перед початком виконання, дозволяючи поставити наступну задачу, поки поточна працює. Усередині батчів унікальність не застосовується.

**Дві різні задачі змінюють ті самі дані одночасно.** Тут допомагає middleware `WithoutOverlapping` із ключем за сутністю: задачі з однаковим ключем виконуються по черзі.

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

`expireAfter` на практиці обов'язковий: якщо воркер помре посеред задачі, блокування без терміну життя залишиться назавжди.

---

<a id="priorities"></a>
## Окремі черги та пріоритети

Одна черга `default` для всього — часта причина скарг «лист для скидання пароля прийшов через двадцять хвилин»: попереду стояли десять тисяч задач імпорту. Розділяйте задачі за характером навантаження:

* `high` — те, на що чекає користувач: листи скидання пароля, сповіщення, вебхуки оплати;
* `default` — звичайні фонові задачі;
* `imports` / `reports` — важкі й довгі, зі своїми таймаутами й лімітами пам'яті.

Воркер обробляє черги за пріоритетом зліва направо: `--queue=high,default`. Для важких черг запускайте окремі воркери з іншими `--timeout` і `--memory`, а отже, і окреме з'єднання з відповідним `retry_after`.

У Laravel 13 маршрутизацію задач по чергах можна зібрати в одному місці замість `->onQueue()` у кожному виклику:

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

Воркери мають працювати постійно й підніматися після падіння, `--max-time` або `queue:restart`. Без менеджера процесів цього не буде.

```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` має бути більшим за найдовшу задачу цієї групи: під час зупинки Supervisor надсилає `SIGTERM`, воркер допрацьовує поточну задачу, і лише після спливання `stopwaitsecs` процес убивається `SIGKILL`. У Docker ту саму роль відіграє `stop_grace_period`.

**Horizon** — для черг на Redis зручніший: конфігурація воркерів у коді, автобалансування процесів між чергами, дашборд із метриками, проваленими задачами й тегами.

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

Правило про таймаути діє й тут: `timeout` супервізора Horizon має бути на кілька секунд меншим за `retry_after` з'єднання й водночас більшим за таймаут будь-якої окремої задачі. Метрики Horizon будуються за знімками — додайте `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Деплой.** Воркери тримають код у пам'яті й не бачать змін без перезапуску. Після перемикання релізу виконайте `php artisan queue:restart` (або `php artisan horizon:terminate`): воркери допрацюють поточні задачі й завершаться, а Supervisor підніме їх із новим кодом. Сигнал перезапуску зберігається в кеші, тому кеш має бути спільним для всіх серверів. Сумісність payload старих задач із новим кодом — окрема тема, її розібрано в статті про [деплой без простою](zero-downtime-deployment-laravel#queues).

---

<a id="monitoring"></a>
## Моніторинг черг

Черги ламаються тихо: веб працює, помилок 500 немає, а листи не надсилаються третю годину. Мінімальний набір сигналів:

* **Довжина черги й час очікування** — зростання черги означає, що воркерів мало або вони стоять.
* **Швидкість провалів** — кількість записів у `failed_jobs` за інтервал.
* **Живучість воркерів** — Horizon показує статус супервізорів; без Horizon стежте за процесами через Supervisor.

Вбудована команда `queue:monitor` перевіряє розмір черг і в разі перевищення порогу надсилає подію `QueueBusy`, на яку можна повісити сповіщення:

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

Докладніше про метрики, логи й алерти — у статті про [спостережуваність](observability-monitoring-laravel).

---

<a id="common-mistakes"></a>
## Типові помилки

**1. `--timeout` більший за `retry_after`.**
Довга задача видається другому воркеру, поки перший її виконує. Результат — дублікати й гонки.

**2. Немає таймауту в HTTP-клієнта.**
Завислий сокет не завжди переривається таймаутом задачі. Воркер стоїть, черга зростає.

**3. Одна спроба за замовчуванням разом із `RateLimited` або `WithoutOverlapping`.**
Перше ж повернення в чергу провалює задачу. Збільште `Tries` і обмежте помилки через `MaxExceptions`.

**4. Неідемпотентні задачі.**
Повтор після таймауту, падіння воркера або `queue:retry` надсилає другий лист чи робить друге списання.

**5. Дані в payload замість ідентифікаторів.**
Великі масиви й моделі зі зв'язками роздувають Redis і `failed_jobs`. Передавайте ID та шляхи до файлів.

**6. Задача з транзакції без `after_commit`.**
Воркер не знаходить ще не закомічений запис або працює зі старими даними.

**7. Воркери без обмеження життя.**
Пам'ять зростає тижнями, доки процес не вб'є OOM killer посеред задачі. Використовуйте `--max-jobs`, `--max-time`, `--memory`.

**8. Забути `queue:restart` після деплою.**
Воркери виконують задачі старим кодом проти нової схеми бази.

**9. `queue:retry all` без розбору причин.**
Задачі падають знову, а неідемпотентні встигають повторити побічні ефекти.

---

<a id="checklist"></a>
## Чеклист

1. Драбину таймаутів узгоджено: HTTP/SQL < таймаут задачі < `retry_after` < `stopwaitsecs` / `stop_grace_period`.
2. Встановлено розширення pcntl; у Redis `block_for` не дорівнює `0`.
3. Для кожної задачі свідомо задано `Tries`, `Backoff` і за потреби `MaxExceptions` або `retryUntil()`.
4. Задачі ідемпотентні; зовнішні виклики використовують ключі ідемпотентності.
5. Задачі надсилаються після коміту (`after_commit` або `ShouldQueueAfterCommit`).
6. У payload — ідентифікатори й шляхи, а не великі дані; моделі без зв'язків.
7. Важкі імпорти розбито на батчі з окремою чергою та своїми воркерами.
8. Воркери перезапускаються за `--max-jobs`, `--max-time`, `--memory` під Supervisor або Horizon.
9. `queue:restart` / `horizon:terminate` — обов'язковий крок деплою.
10. `queue:prune-failed` і `queue:prune-batches` стоять у розкладі, на розмір черг і швидкість провалів налаштовано алерти.

---

## Підсумок

Черги в продакшені надійні настільки, наскільки узгоджені їхні числа й ідемпотентні їхні задачі. Таймаути вибудовуються драбиною, повтори налаштовуються під характер помилки, важка робота дробиться на маленькі повторювані частини, а воркери живуть обмежений час і перезапускаються під час кожного деплою. Тоді `failed_jobs` перетворюється із цвинтаря на робочий список, який можна розібрати й повторити.

---

<a id="self-test-quiz"></a>
## Квіз для самоперевірки

### Питання 1: `retry_after` з'єднання дорівнює 90 секундам, воркер запущено з `--timeout=300`. Задача виконується 200 секунд. Що станеться?
- А) Задача спокійно завершиться — у воркера є запас за таймаутом.
- Б) Через 90 секунд з'єднання видасть задачу іншому воркеру, і вона почне виконуватися вдруге паралельно з першою.
- В) Воркер перерве задачу через 90 секунд.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
`retry_after` відраховує брокер, який не знає, чи живий воркер. Через 90 секунд задача вважається покинутою й видається знову. Тому `--timeout` має бути на кілька секунд меншим за `retry_after`.
</details>

### Питання 2: Задача використовує middleware `RateLimited` і падає в `failed_jobs`, так жодного разу й не виконавши `handle()`. Чому?
- А) `RateLimited` не працює з драйвером Redis.
- Б) Повернення задачі в чергу через ліміт витрачає спробу, а за замовчуванням у задачі лише одна спроба.
- В) Middleware виконується після `handle()`.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
Кожен `release()` — це витрачена спроба. Для задач із rate limiting збільшують `Tries` (або використовують `retryUntil()`), а кількість реальних помилок обмежують через `MaxExceptions`.
</details>

### Питання 3: Як правильно імпортувати файл на 400 000 рядків через чергу?
- А) Одна задача з `#[Timeout(7200)]` і `--memory=4096` у воркера.
- Б) Батч із невеликих ідемпотентних задач, кожна обробляє свій діапазон рядків, в окремій черзі зі своїми воркерами.
- В) Передати всі рядки файлу в конструктор задачі, щоб воркерові не потрібно було читати файл.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
Маленькі частини вкладаються в таймаути й ліміти пам'яті, повторюються незалежно й показують прогрес. Варіант А втрачає всю роботу в разі падіння на останньому рядку, а варіант В роздуває payload, пам'ять Redis і `failed_jobs`.
</details>