<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE ЧАСТО ПИТАЮТЬ

Як налаштувати черги, повтори й обробку помилок job у Laravel?

Кількість спроб задають `$tries` (або `$maxExceptions`), паузу між ними — `$backoff` чи метод `backoff()`, дедлайн — `retryUntil()`, а фінальний обробник — `failed(Throwable $e)`. Ключова пастка не в цих властивостях, а в тому, що `retry_after` у config/queue.php має бути більшим за `$timeout` job, інакше воркер підхопить ще працюючий job і виконає його вдруге. Horizon дає ті самі налаштування на рівні супервізора, дашборд і теги; `ShouldBeUnique`, `Bus::batch()` і `Bus::chain()` керують не повторами, а тим, що взагалі потрапить у чергу і в якому порядку.

Лист клієнту прийшов тричі, хоча job виконався успішно — як таке можливо?
У вас `public $tries = 3`, а в `failed_jobs` порожньо, хоча job явно падає — куди дівся запис?
Job кидає ModelNotFoundException одразу після `Order::create()` в транзакції — чому?
Скільки разів повториться job, у якого є і `$tries = 5`, і `retryUntil()` на годину вперед?
Зовнішнє API повернуло 429 — як зробити паузу для всієї черги, а не для одного job?
Queue Horizon retries failed jobs batch chain ShouldBeUnique

Черга в Laravel — це два незалежні механізми, які початківці зливають в один. Перший: драйвер зберігає серіалізований payload і при pop() резервує його — ставить позначку reserved_at (database) або переносить у sorted set :reserved (redis). Другий: воркер queue:work бере job, викликає handle() і, якщо винятку не було, видаляє його з черги. Між резервуванням і видаленням є вікно, у якому процес може померти, і саме тому черга дає гарантію at-least-once, а не exactly-once. Параметр retry_after у config/queue.php каже, через скільки секунд вважати зарезервований job загубленим і повернути його в роботу; $timeout job (і --timeout воркера) — через скільки секунд убити процес обробки. Якщо retry_after менший за $timeout, черга віддасть job другому воркеру, поки перший ще працює, і ви отримаєте дубль, який не пояснюється жодним $tries. Це найчастіша реальна причина «листа тричі», і перше, що варто назвати на співбесіді.

Кількість повторів задають на трьох рівнях, і вони комбінуються. public int $tries (або --tries воркера, або tries супервізора Horizon) — це ліміт спроб: кожне взяття job з черги, включно з поверненням через $this->release(30) і з таймаутом. public int $maxExceptions — ліміт саме необроблених винятків; він потрібен там, де job легально повертається в чергу десятки разів (middleware WithoutOverlapping, RateLimited, ThrottlesExceptions), і без нього щедрий $tries перетворює три реальні помилки на двадцять п'ять. retryUntil(): DateTimeInterface задає дедлайн замість лічильника: після цього моменту job більше не повторюється незалежно від спроб. Важлива деталь механіки — значення retryUntil() обчислюється один раз при диспатчі й лягає в payload разом із job, тому повтори його не зсувають; якщо задано і $tries, і retryUntil(), спрацює те, що настане раніше. Паузу між спробами описує public $backoff або метод backoff(): число дає однакову затримку, масив [10, 60, 300] — прогресію (останнє значення повторюється для всіх подальших спроб). Для зовнішніх API майже завжди треба експоненційна прогресія плюс джиттер, інакше сотня job, що впала на одному збої, синхронно повернеться в те саме API.

Коли спроби вичерпано, воркер викликає failed(Throwable $e) на job і пише рядок у failed_jobs (провайдер database-uuids за замовчуванням). Тут ховається пастка, яку перевіряють на middle-рівні: failed() виконується на новому екземплярі, відновленому з payload, а не на тому, що працював у handle(). Усе, що ви присвоїли властивостям під час обробки, там уже недоступне — у failed() є лише конструкторські дані та виняток. Друга пастка симетрична: якщо ви обгорнули тіло handle() у try/catch і мовчки залогували помилку, job вважається успішним, failed() не викличеться і в failed_jobs не буде нічого — «падає, але нічого не пишеться» майже завжди означає саме це. Явно провалити job можна через $this->fail($e). Глобально фейли ловлять хуком Queue::failing() у сервіс-провайдері; далі — queue:failed, queue:retry {uuid}, queue:forget, а щоб таблиця не росла вічно — queue:prune-failed --hours=168 у планувальнику. Окремий випадок: job із SerializesModels, чия модель видалена, падає з ModelNotFoundException на кожному повторі — правильна реакція не ретрай, а public bool $deleteWhenMissingModels = true.

Horizon — це не інший механізм черг, а надбудова над тим самим воркером, і працює вона тільки з Redis. Конфіг воркерів переїжджає з supervisor у config/horizon.php, де на кожен супервізор описані черги, processes, tries, timeout, memory і стратегія balance: simple ділить процеси між чергами порівну, auto перерозподіляє їх динамічно між minProcesses і maxProcesses (за часом розгрібання або за розміром черги), false обробляє черги строго за пріоритетом. Взамін ви отримуєте дашборд із пропускною здатністю, часом виконання і стектрейсами падінь, теги через метод tags() (щоб знайти всі job однієї сутності) та нотифікації про довге очікування. Дві операційні деталі, які люблять питати: horizon:snapshot має стояти в планувальнику, бо без нього метрики порожні, а на деплої викликають horizon:terminate (аналог queue:restart) — інакше воркери продовжать виконувати старий код, який вони тримають у памʼяті ще з моменту старту.

Останній шар — керування тим, що взагалі потрапляє в чергу і в якому порядку. ShouldBeUnique не сканує чергу: при диспатчі він бере атомарний лок у кеші за uniqueId() на $uniqueFor секунд і, якщо лок зайнятий, просто не диспатчить job — тихо, без винятку. Потрібен стор із локами (redis, memcached, dynamodb, database), спільний для всіх воркерів; ShouldBeUniqueUntilProcessing знімає лок на початку handle(), а від одночасного виконання захищає інший інструмент — middleware WithoutOverlapping. Bus::chain([...]) виконує job послідовно, диспатчачи наступний лише після успіху попереднього; падіння обриває ланцюжок і викликає catch(), замикання якого серіалізується, тому $this в нього затягувати не можна. Bus::batch([...]) виконує job паралельно, рахує прогрес у job_batches і дає then(), catch() (лише для першого падіння), finally(); за замовчуванням перше падіння скасовує батч, що вимикається через allowFailures(), а вже поставлені в чергу job усе одно будуть узяті — тому в них перевіряють $this->batch()->cancelled() або вішають SkipIfBatchCancelled. І наскрізне правило, яке важливіше за всі ці властивості: dispatch()->afterCommit() (або after_commit => true у конфізі зʼєднання), бо job, відправлений усередині транзакції, регулярно виграє гонку в COMMIT і не знаходить у базі рядок, заради якого його створили.

final class ChargeInvoice implements ShouldQueue, ShouldBeUnique
{
    use Batchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 25;          // спроб разом із release() від middleware
    public int $maxExceptions = 3;   // а реальних винятків — лише три
    public int $timeout = 60;        // менший за retry_after у config/queue.php!
    public bool $failOnTimeout = true;
    public bool $deleteWhenMissingModels = true;
    public int $uniqueFor = 300;     // страховка, якщо воркера вбили

    public function __construct(public Invoice $invoice) {}

    public function uniqueId(): string
    {
        return (string) $this->invoice->id;
    }

    /** Пауза між спробами: 10 с, 60 с, 5 хв. */
    public function backoff(): array
    {
        return [10, 60, 300];
    }

    /** Обчислюється один раз при диспатчі й лягає в payload. */
    public function retryUntil(): DateTimeInterface
    {
        return now()->addHours(2);
    }

    public function middleware(): array
    {
        // 429 від провайдера: перші 5 винятків за 10 хв — не спамимо API.
        return [new ThrottlesExceptions(5, 10 * 60), new SkipIfBatchCancelled];
    }

    public function handle(PaymentGateway $gateway): void
    {
        if ($this->invoice->isPaid()) {
            return; // ідемпотентність: at-least-once означає можливий дубль
        }

        $gateway->charge($this->invoice, idempotencyKey: "inv-{$this->invoice->id}");
    }

    /** Новий екземпляр із payload: властивостей із handle() тут уже немає. */
    public function failed(Throwable $e): void
    {
        $this->invoice->markChargeFailed($e->getMessage());
    }
}

// Диспатч після COMMIT, інакше воркер не побачить щойно створений рядок.
ChargeInvoice::dispatch($invoice)->afterCommit()->onQueue('payments');
Що `retry_after` у конфізі зʼєднання і `$timeout` job — різні речі: перший каже, через скільки секунд чергу вважати job загубленим і віддати іншому воркеру, і він мусить бути більшим за timeout, інакше job виконається двічі паралельно.
Що `failed()` викликається на новому екземплярі, відновленому з payload: усе, що ви записали у властивості під час `handle()`, там уже втрачене.
Що `retryUntil()` обчислюється один раз при диспатчі й кладеться в payload, тому повтори його не зсувають; а якщо є і `$tries`, і `retryUntil()`, спрацює те, що настане раніше.
Що job має бути ідемпотентним, бо at-least-once доставка гарантована, а exactly-once — ні: воркера можуть вбити після сайд-ефекту, але до видалення job з черги.
Що `ShouldBeUnique` тримає лок у кеші (потрібен стор з атомарними локами), знімає його після завершення або падіння, а `ShouldBeUniqueUntilProcessing` — на початку `handle()`.
Що за замовчуванням перший фейл у батчі скасовує весь батч, і що `allowFailures()` це вимикає, а `catch()` викликається лише для першого падіння.
Ставити `$timeout = 300` при дефолтному `retry_after = 90` і потім дивуватися дублям: job ще працює, а черга вже віддала його другому воркеру.
Вважати, що `$tries = 3` рахує винятки. Воно рахує спроби: кожен `release()` і кожен таймаут теж збільшують `attempts()`. Рахує саме винятки `$maxExceptions`.
Диспатчити job усередині `DB::transaction()` без `->afterCommit()` — воркер підхоплює його швидше, ніж відбувся COMMIT, і отримує `ModelNotFoundException`.
Писати логіку компенсації у `failed()` і покладатися на `$this->результат`, порахований у `handle()`: об'єкт інший, властивості з payload.
Не перезапускати воркери після деплою: `queue:work` тримає завантажений код у памʼяті, тож потрібен `queue:restart` або `horizon:terminate`.
Ловити виняток у `handle()` через `try/catch` і мовчки логувати — job вважається успішним, `failed()` не викличеться, у `failed_jobs` нічого не буде.
Плутати `$backoff` з паузою після падіння всієї черги: для 429 і 503 потрібні middleware `RateLimited` або `ThrottlesExceptions`, які тримають лок для всіх job класу.
ПОРАДА

Почніть не з `$tries`, а з фрази «черга гарантує at-least-once, тому job має бути ідемпотентним» — і одразу назвіть два джерела дублів: `retry_after` менший за `$timeout` і сайд-ефект без ключа ідемпотентності. Далі складіть картину з трьох рівнів: скільки разів (`$tries`/`$maxExceptions`/`retryUntil`), з якою паузою (`$backoff`, exponential + jitter), що робити наприкінці (`failed()`, `queue:retry`, `queue:prune-failed`). Horizon згадуйте як те, що дає ті самі опції на рівні супервізора плюс метрики й теги, а не як заміну розуміння.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`retry_after` — це час, після якого черга вважає зарезервований job загубленим; якщо він менший за `$timeout`, другий воркер підхопить job, який ще виконується, і сайд-ефект станеться двічі. `$tries` рахує саме спроби, а не винятки (для винятків є `$maxExceptions`), і `release()` теж збільшує `attempts()`. `retryUntil()` обчислюється один раз при диспатчі й зберігається в payload. `failed()` отримує новий екземпляр, відновлений із payload.