---
title: 'Опашките на Laravel в продукция: таймаути и failed_jobs | DevSense'
description: 'Как да настроите опашките на Laravel за продукция: retry_after и --timeout, повторни опити и backoff, failed_jobs, паметта на дълго живеещите worker-и, тежки импорти чрез batch-ове, уникални задачи, Supervisor и Horizon.'
faq:
    - { question: 'Каква е разликата между retry_after и --timeout в опашките на Laravel?', answer: 'retry_after се задава в config/queue.php и казва на връзката след колко секунди да смята взетата задача за увиснала и да я подаде отново. --timeout (или атрибутът #[Timeout] на задачата) ограничава колко секунди worker-ът дава на задачата да се изпълнява, преди да приключи с грешка. Таймаутът трябва да е с няколко секунди по-малък от retry_after, иначе задачата може да се изпълни два пъти.' }
    - { question: 'Защо задача от опашката се изпълни два пъти?', answer: 'Най-често защото задачата е работила по-дълго от retry_after и връзката я е подала на втори worker или worker-ът е бил убит по време на изпълнението (деплой, OOM, SIGKILL) и задачата се е върнала в опашката. Затова задачите трябва да са идемпотентни, а таймаутите — съгласувани: HTTP клиент < таймаут на задачата < retry_after.' }
    - { question: 'Защо расте паметта на worker-а на опашката?', answer: 'Worker-ът queue:work е дълго живеещ процес, който не презарежда фреймуърка между задачите. Памет трупат статичните масиви и singleton-ите със състояние, логът на заявките, Telescope, GD изображенията и големите колекции. Ограничете живота на worker-а с флаговете --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` са се натрупали двайсет хиляди записа и никой не знае кои от тях са важни. След деплоя worker-ите цяла седмица изпълняват задачите със стария код.

Тази статия е за това как да настроите опашките на Laravel така, че да преживяват сривове, деплои и големи обеми: как са свързани `retry_after` и `--timeout`, как работят повторните опити, какво да правите с `failed_jobs`, защо расте паметта на worker-ите и как да раздробявате тежките импорти. Локалното стартиране на опашките в Docker е разгледано в статията [Sail: опашки и worker-и](../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)
* [Паметта на дълго живеещите worker-и](#memory)
* [Тежки импорти и експорти](#heavy-jobs)
* [Дубликати и race conditions: уникални задачи и after_commit](#uniqueness)
* [Отделни опашки и приоритети](#priorities)
* [Supervisor и Horizon](#supervisor)
* [Мониторинг на опашките](#monitoring)
* [Чести грешки](#common-mistakes)
* [Чеклист](#checklist)
* [Тест за самопроверка](#self-test-quiz)

---

<a id="lifecycle"></a>
## Жизнен цикъл на задачата: опити, връщане, провал

Когато worker-ът вземе задача, започва **опит** (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`. Той отговаря на въпроса „след колко секунди взетата задача да се смята за изоставена и да се подаде на друг worker“. Брокерът не знае дали worker-ът е жив, затова се ориентира само по времето. При Amazon SQS вместо него се използва Visibility Timeout в настройките на опашката.
* **`--timeout`** на `queue:work` (или атрибутът `#[Timeout]` на задачата) — колко секунди worker-ът дава на задачата да се изпълнява. По подразбиране — 60 секунди. Ако времето изтече, процесът на worker-а приключва с грешка, а Supervisor вдига нов. За таймаутите е нужно разширението **pcntl**.

Ако `--timeout` е по-голям от `retry_after`, задача, която работи по-дълго от `retry_after`, ще бъде **подадена на втори worker, докато първият все още я изпълнява**. Оттам идват дублираните имейли, двойните тегления и race conditions. Документацията формулира правилото директно: `--timeout` трябва да е поне с няколко секунди по-кратък от `retry_after`.

На практика трябва да съгласувате цяла стълба от стойности — всяка следваща е по-голяма от предходната:

| Ниво | Къде се задава | Пример |
|---------|--------------|--------|
| Таймаути на HTTP клиента и SQL | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 с |
| Таймаут на задачата | `#[Timeout(120)]` или `--timeout=120` | 120 с |
| `retry_after` на връзката | `config/queue.php` | 150 с |
| Изчакване на спирането на worker-а | `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 връзки) може да не реагира на таймаута на worker-а. Винаги задавайте собствени таймаути на HTTP клиента и на заявките към базата.

`block_for` при Redis казва на драйвера колко секунди да чака нова задача в блокиращ режим. Стойност `0` блокира worker-а безкрайно — и той спира да обработва сигнали като `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` на worker-а.
* **`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]
> **Повторният опит е безопасен само ако задачата е идемпотентна.** Задачата може да се изпълни повторно след таймаут, срив на worker-а или вашия `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>
## Паметта на дълго живеещите worker-и

`queue:work` е демон: той зарежда приложението веднъж и изпълнява задачите в цикъл, без да рестартира фреймуърка. Това е бързо, но всичко, което задачата е оставила в паметта, остава там до края на живота на процеса. Типични източници на растеж:

* статични масиви и singleton-и, които трупат състояние („кеш“ в свойство на сървис);
* логът на заявките (`DB::enableQueryLog()`), Telescope и Debugbar в продукция;
* GD/Imagick ресурси без `imagedestroy()` или `clear()`;
* големи колекции, заредени изцяло чрез `get()` вместо `lazyById()`.

Защитата е да ограничите живота на worker-а и да оставите процес мениджъра да го рестартира:

```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-ия ред. Работещата схема е работата да се раздели на много малки идемпотентни задачи, обединени в **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']);
    }
}
```

Кое е важно тук:

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

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

Ако задачата приема модел, Laravel го сериализира заедно със заредените релации. Атрибутът `#[WithoutRelations]` (или `$model->withoutRelations()`) оставя в payload-а само идентификатора — моделът ще бъде зареден наново от базата при изпълнението.

---

<a id="uniqueness"></a>
## Дубликати и race conditions: уникални задачи и after_commit

**Задачата стартира, преди данните да са commit-нати.** Класически бъг: задачата се изпраща вътре в транзакция, worker-ът я взима мигновено, търси поръчката по ID — а транзакцията още не е commit-ната. `ModelNotFoundException` или по-лошо — задачата работи със стари данни. Решения:

* `'after_commit' => true` в настройките на връзката — всички задачи, събития в опашка, имейли и известия чакат commit;
* интерфейсът `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` го освобождава преди началото на изпълнението, като позволява да се постави следваща задача, докато текущата работи. Вътре в batch-овете уникалността не се прилага.

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

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

`expireAfter` на практика е задължителен: ако worker-ът умре по средата на задачата, заключване без срок на живот ще остане завинаги.

---

<a id="priorities"></a>
## Отделни опашки и приоритети

Една опашка `default` за всичко е честа причина за оплаквания от рода на „имейлът за смяна на паролата дойде след двайсет минути“: пред него са чакали десет хиляди задачи за импорт. Разделяйте задачите според характера на натоварването:

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

Worker-ът обработва опашките по приоритет отляво надясно: `--queue=high,default`. За тежките опашки стартирайте отделни worker-и с други `--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

Worker-ите трябва да работят постоянно и да се вдигат след срив, `--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`, worker-ът довършва текущата задача и едва след изтичането на `stopwaitsecs` процесът се убива със `SIGKILL`. В Docker същата роля играе `stop_grace_period`.

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

```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 се изграждат от snapshot-и — добавете `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Деплой.** Worker-ите държат кода в паметта и не виждат промените без рестарт. След превключването на релийза изпълнете `php artisan queue:restart` (или `php artisan horizon:terminate`): worker-ите ще довършат текущите задачи и ще приключат, а Supervisor ще ги вдигне с новия код. Сигналът за рестарт се пази в кеша, затова кешът трябва да е общ за всички сървъри. Съвместимостта на payload-а на старите задачи с новия код е отделна тема, разгледана в статията за [деплой без прекъсване](zero-downtime-deployment-laravel#queues).

---

<a id="monitoring"></a>
## Мониторинг на опашките

Опашките се чупят тихо: уебът работи, грешки 500 няма, а имейлите не тръгват от три часа. Минимален набор от сигнали:

* **Дължина на опашката и време на изчакване** — растящата опашка означава, че worker-ите са малко или стоят.
* **Скорост на проваляне** — броят записи във `failed_jobs` за даден интервал.
* **Жизненост на worker-ите** — 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`.**
Дългата задача се подава на втори worker, докато първият я изпълнява. Резултатът — дубликати и race conditions.

**2. Няма таймаут на HTTP клиента.**
Увисналият сокет не винаги се прекъсва от таймаута на задачата. Worker-ът стои, опашката расте.

**3. Един опит по подразбиране заедно с `RateLimited` или `WithoutOverlapping`.**
Още първото връщане в опашката проваля задачата. Увеличете `Tries` и ограничете грешките чрез `MaxExceptions`.

**4. Неидемпотентни задачи.**
Повторен опит след таймаут, срив на worker-а или `queue:retry` изпраща втори имейл или прави второ теглене.

**5. Данни в payload-а вместо идентификатори.**
Големите масиви и моделите с релации раздуват Redis и `failed_jobs`. Подавайте ID и пътища до файлове.

**6. Задача от транзакция без `after_commit`.**
Worker-ът не намира още некомитнатия запис или работи със стари данни.

**7. Worker-и без ограничение на живота.**
Паметта расте със седмици, докато OOM killer не убие процеса по средата на задача. Използвайте `--max-jobs`, `--max-time`, `--memory`.

**8. Забравен `queue:restart` след деплой.**
Worker-ите изпълняват задачи със стария код срещу новата схема на базата.

**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. Задачите се изпращат след commit (`after_commit` или `ShouldQueueAfterCommit`).
6. В payload-а има идентификатори и пътища, а не големи данни; моделите са без релации.
7. Тежките импорти са разбити на batch-ове с отделна опашка и собствени worker-и.
8. Worker-ите се рестартират по `--max-jobs`, `--max-time`, `--memory` под Supervisor или Horizon.
9. `queue:restart` / `horizon:terminate` е задължителна стъпка от деплоя.
10. `queue:prune-failed` и `queue:prune-batches` са в графика, за размера на опашките и скоростта на проваляне са настроени алерти.

---

## Обобщение

Опашките в продукция са толкова надеждни, колкото са съгласувани числата им и идемпотентни задачите им. Таймаутите се подреждат в стълба, повторните опити се настройват според характера на грешката, тежката работа се раздробява на малки повторяеми части, а worker-ите живеят ограничено време и се рестартират при всеки деплой. Тогава `failed_jobs` се превръща от гробище в работен списък, който може да се прегледа и повтори.

---

<a id="self-test-quiz"></a>
## Тест за самопроверка

### Въпрос 1: `retry_after` на връзката е 90 секунди, worker-ът е стартиран с `--timeout=300`. Задачата се изпълнява 200 секунди. Какво ще се случи?
- А) Задачата спокойно ще приключи — worker-ът има резерв по таймаута.
- Б) След 90 секунди връзката ще подаде задачата на друг worker и тя ще започне да се изпълнява втори път паралелно с първия.
- В) Worker-ът ще прекъсне задачата след 90 секунди.

<details>
<summary>Покажи правилния отговор</summary>

**Правилен отговор: Б**
`retry_after` се отброява от брокера, който не знае дали worker-ът е жив. След 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` на worker-а.
- Б) Batch от малки идемпотентни задачи, всяка от които обработва своя диапазон от редове, в отделна опашка със собствени worker-и.
- В) Да подадете всички редове от файла в конструктора на задачата, за да не се налага worker-ът да чете файла.

<details>
<summary>Покажи правилния отговор</summary>

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