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