A Worker That Dies at 3 a.m.: Supervisor and Graceful Restarts
At 03:12 a job throws an uncaught Error, PHP-FPM-style worker process exits, and there is nobody there to restart it. At 08:00 the queue has 12 000 pending jobs and every email is late. The worker was never broken — it just had no supervisor, and a queue worker is a process that must outlive any single job.
The process you are supervising
Start from the right artisan command:
php artisan queue:work redis \
--queue=emails,default \
--sleep=3 --tries=3 --max-time=3600 --memory=128
| Flag | Meaning | Why it is there |
|---|---|---|
queue:work |
long-lived worker, boots the app once | queue:listen re-bootstraps per job — 50–100 ms of pure overhead each |
--sleep=3 |
idle poll interval | bounds DB/Redis chatter when the queue is empty |
--tries=3 |
default attempts when the job defines none | the fallback half of the retry contract |
--max-time=3600 |
self-terminate after an hour | rotates code and leaks without a deploy |
--memory=128 |
exit above 128 MB | memory_limit creep is the #1 silent killer of daemons |
The worker checks memory between jobs and exits cleanly when it crosses the limit — supervisor then starts a fresh process. Without that line, a worker that slowly accumulates memory dies as a fatal Allowed memory size exhausted at the worst moment, or worse, drags the box down with it.
Supervisor: the restart policy
; /etc/supervisor/conf.d/worker-emails.conf
[program:worker-emails]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=emails,default --sleep=3 --tries=3 --max-time=3600
numprocs=4
autostart=true
autorestart=true # restart on any exit, including success-exits
startsecs=5 # healthy only after 5s of survival
stopwaitsecs=650 # MUST exceed your longest job (job timeout + margin)
stopsignal=TERM # queue:work finishes the current job, then exits
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
user=www-data
stopasgroup=true
killasgroup=true # no orphaned children when the group is stopped
supervisorctl reread && supervisorctl update
supervisorctl status # RUNNING / BACKOFF / FATAL — check after every deploy
supervisorctl restart worker-emails:*
stopwaitsecs deserves emphasis: supervisor sends SIGTERM, waits that many seconds, then SIGKILL. Set it longer than your slowest job, or a deploy will sever a running export mid-write and burn an attempt. Laravel's worker handles SIGTERM gracefully — it stops accepting new jobs and exits after the current one — which is exactly the behavior you want on php artisan queue:restart too: a graceful, deploy-safe bounce of every worker.
autorestart=true covers crashes; startsecs plus a health check covers the half-dead case (a worker running but unable to reach Redis). BACKOFF in supervisorctl status means it is crash-looping — read tail -f storage/logs/worker.log before restarting blindly, because the third restart usually fails the same way. On systemd-only boxes the same contract is three lines:
[Service]
ExecStart=/usr/bin/php /var/www/app/artisan queue:work redis --queue=default
Restart=always
RestartSec=5
MemoryMax=512M
Deploys without dropped jobs
The sequence that works: ship code → php artisan queue:restart (or php artisan horizon:terminate for Horizon) → wait for workers to report stopped in logs → new code is picked up by supervisor's restarted processes. Workers started before a code change keep serving old classes until they exit; --max-time and queue:restart are the two mechanisms that bound that staleness.
This is process-level reliability. The job-level half — what happens when an individual job throws three times — is Dispatching Queued Jobs, and the numbers you watch once this is running are in the third chapter below. Cron-driven maintenance of the same fleet lives in Scheduling Recurring Tasks.
Stopping the Poison Message: Retry Budgets, Backoff, and Dead Letters
A job is broken: every attempt throws, and the queue drains at zero while your workers burn CPU on the same poison message. Retry limits and backoff are the two knobs that stop that, and the first interview question is always about how the two configuration layers combine.
Where the retry budget comes from
Three sources can set maxTries, and they resolve in a fixed order:
class SyncInventory implements ShouldQueue
{
public int $tries = 5; // 1. the job itself (wins when set)
public int $maxExceptions = 3; // cap on *exceptions*, across attempts
public int|array $backoff = [10, 60, 300]; // 2. escalating delays per attempt
public ?int $timeout = 90; // kill switch (requires pcntl)
}
php artisan queue:work --tries=3 # 3. worker default, only when the job is silent
The job's $tries beats the worker's --tries; the worker flag is only the fallback for jobs that declare nothing. With neither, attempts are unlimited — the config value queue.php → tries (or -1) makes that explicit rather than accidental. $maxExceptions is the stricter sibling: it counts exceptions even when you release() the job yourself, so a job that fails once every attempt but never exhausts $tries still gets cut off.
Backoff is what makes retries polite
public int|array $backoff = [10, 60, 300]; // delay before attempt 2, 3, 4
public function retryUntil(): \DateTime
{
return now()->addHours(2); // hard deadline: after this, fail immediately
}
Immediate retries are a denial-of-service tool pointed at your own dependency: if the inventory API is down, three instant attempts fail in 200 ms and you have proved nothing. Escalating backoff gives the upstream time to recover and spreads the herd. retryUntil() caps the whole lifecycle — after the deadline the job goes straight to failed_jobs instead of waiting out the next backoff slot.
The pieces around the job itself:
php artisan queue:failed # id, queue, exception, failed_at — run this first
php artisan queue:retry all # push them back
php artisan queue:retry 9b2f1c
php artisan queue:forget 9b2f1c # accept the loss
php artisan queue:prune --hours=48 # keep the table honest
For SQS, the same contract lives in the queue's redrive policy: after maxReceiveCount receives, SQS moves the message to a dead-letter queue and your failed_jobs table never sees it — know which side of that boundary your driver sits on. Framework-side, failed() on the job is where you release locks, mark rows, and increment the alerting counter.
A loop that cannot end
The invariant to state in an interview: every job must have a finite terminal state — success, failed_jobs, or DLQ — reachable without human action. Concretely:
retryUntil()or$triesset on every job class (code review enforces it).- Backoff array, never bare
release()in a loop. - An alert on
failed_jobsgrowth rate, not just on its size. - Poison messages removed via
queue:forgetafter root-causing, so the retry storm stops.
Jobs that legitimately retry forever (heartbeats, pollers) are the exception, and they belong on a short --max-time worker pool, isolated from the main queue — a Horizon tries/backoff/timeout triple per queue in config/horizon.php is how you express that isolation declaratively.
The other half of reliability is knowing when the queue is slow rather than broken: wait time and throughput are covered next, and if you skipped Dispatching Queued Jobs, revisit it for where attempts are recorded in the first place.
Reading the Queue Before Users Complain: Wait Time, Throughput, and Graceful Deploys
The queue is not down — it is slow, and you will not find out from a 500 page. When workers fall behind, everything still succeeds; jobs just take 40 minutes. Queue wait time is the early-warning metric, and it is the number producers-outpacing-workers shows up in first.
What to measure
| Metric | Source | Reads as |
|---|---|---|
Waiting jobs (Queue::size()) |
redis-cli LLEN queues:default |
backlog depth right now |
| Wait time (dispatch → start) | Horizon dashboard | latency users actually feel |
| Throughput (jobs/min) | Horizon metrics snapshot | drain capacity |
| Failed count & rate | queue:failed / alert |
correctness, not speed |
| Worker liveness | supervisorctl status |
capacity you think you have |
Wait time is the one to alert on: depth without wait time is misleading (a queue with 5 000 jobs and 64 workers may drain in seconds), but a wait time climbing monotonically means arrival rate exceeds service rate and the backlog will grow without bound. The math is one line: if jobs arrive at λ and each worker serves μ, you need ceil(λ / μ) workers with headroom — and Horizon's throughput chart hands you both numbers.
Instrumentation you can type today
php artisan queue:monitor redis:emails,redis:default --max=10
# exits non-zero when any listed queue exceeds --max waiting jobs → wire into cron/healthcheck
redis-cli llen queues:default # raw depth, no PHP boot
redis-cli zrange horizon:metrics:wait -- -10 -1 WITHSCORES # recent wait-time samples
supervisorctl status | grep -c RUNNING # capacity check
queue:monitor is the piece most people miss: it is designed for a monitoring hook (Nagios, a load balancer health check, a cron that pages when emails exceeds 100 waiting jobs). Horizon adds the dashboard: wait time, recent jobs, throughput, and process counts, refreshed by horizon:snapshot — schedule it:
Schedule::command('horizon:snapshot')->everyFiveMinutes();
Scaling: more workers vs bigger workers
numprocs=8 ; supervisor: fixed pool
// config/horizon.php — auto-scaling balances per queue
'balances' => ['redis'],
'scanning' => ['enabled' => true, 'maxProcesses' => 16, 'balanceMaxShift' => 1, 'balanceCooldown' => 3],
'metrics' => ['trim_snapshots' => ['jobs' => 60, 'jobs:pending' => 60]],
Before adding processes, check the bottleneck is CPU-bound worker capacity and not a serialized lock: 16 workers hammering one inventory API rate limit will not help. Then decide which queue gets more capacity — priority queues (--queue=emails,default, Horizon's per-queue maxProcesses) let interactive email work skip behind nightly exports, at the cost of starving default if the high queue never empties.
Deploys and restarts that do not drop work
php artisan queue:restart # every worker exits after its current job (supervisor restarts them)
php artisan horizon:terminate # Horizon: master re-execs, workers finish gracefully
php artisan horizon:status # paused / running — check after deploy
supervisorctl status # confirm RUNNING, not BACKOFF
A graceful restart is the contract: stop taking new jobs, finish the current one, exit, come back on new code. Anything shorter (kill -9, systemctl stop without stopwaitsecs headroom) converts a deploy into duplicate side effects — the job's attempt counter goes up and your idempotency code from Dispatching Queued Jobs has to absorb it.
Interview framing: "How do you know your queue is healthy?" Answer with three numbers — wait time, throughput, and depth — plus the liveness check, then say which alarm fires first when you double traffic: wait time, because depth only grows after workers saturate. The heartbeat that keeps these workers fed with scheduled dispatches is Scheduling Recurring Tasks; the next phase moves to making the request path itself fast, starting with Enabling OPcache.
Quiz
1. A supervisor program runs your queue:work workers with stopwaitsecs=10 (the default) and jobs that take up to 60 seconds. What happens on every deploy restart?
2. A job declares $tries = 5 and you start workers with queue:work --tries=3. How many attempts does the job get?
3. Which invariant guarantees a failing job cannot retry forever?
4. Queue depth is stable but Horizon's wait time has climbed from 2 s to 40 s. What does that tell you?
Flashcards
Question
The supervisor settings that make worker restarts safe
Click to reveal answer
Answer
autorestart=true (restart on crash), stopsignal=TERM (Laravel finishes the current job first), stopwaitsecs LONGER than your slowest job (else SIGKILL mid-job), startsecs to define healthy, stopasgroup/killasgroup to avoid orphans, plus --max-time and --memory for self-rotation of leaks and stale code.
Question
queue:work vs queue:listen, and why it matters at volume
Click to reveal answer
Answer
queue:work boots the framework once and loops, adding only queue polling per job; queue:listen spawns a fresh process per job, paying full bootstrap (tens to hundreds of ms) every time. Production runs queue:work; listen is a local convenience.
Question
The retry configuration layers and their precedence
Click to reveal answer
Answer
Job $tries/$maxExceptions (payload) > worker --tries > config queue.tries (unlimited/-1 by default in older setups). Add $backoff per attempt, retryUntil() as a hard deadline, and $timeout (pcntl) as the kill switch — together they bound a poison message's lifetime.
Question
The three queue metrics to alert on, and what each answers
Click to reveal answer
Answer
Wait time (dispatch→start): are users feeling delay — rises first when producers outpace workers. Throughput (jobs/min): drain capacity. Depth (Queue::size / LLEN): backlog size — grows only after saturation. Plus failed_jobs growth for correctness and supervisorctl status for liveness.
Question
A deploy sequence that does not drop or duplicate jobs
Click to reveal answer
Answer
Ship code → php artisan queue:restart (or horizon:terminate) so workers finish the current job and exit → supervisor/systemd starts fresh processes on the new code → verify supervisorctl status RUNNING and Horizon status. Bounded by --max-time for workers that miss the signal.
Revision Notes
Key Takeaways
- 1. A queue worker is a daemon: supervisor (or systemd Restart=always) plus --max-time and --memory keep it alive, fresh, and leak-free
- 2. stopwaitsecs must exceed your slowest job — the graceful SIGTERM contract is what turns a deploy into a drain instead of a mid-write SIGKILL
- 3. Retry limits come from the job first, the worker flag second; escalating backoff and retryUntil give every job a finite path to failed_jobs
- 4. Wait time is the leading indicator — it climbs before depth does, and queue:monitor turns hot-queue thresholds into alerting
- 5. Graceful restarts (queue:restart, horizon:terminate) finish in-flight work before exit; idempotent handlers absorb the jobs that replay anyway
Interview Tips
- • "How do you keep queue workers running in production?" — supervisor with autorestart, explicit stopsignal/stopwaitsecs, numprocs sized from throughput, plus --max-time/--memory rotation; note the systemd Restart=always equivalent
- • "How do you deploy without losing jobs?" — queue:restart or horizon:terminate, workers finish current job and exit, supervisor brings up new code; mention stopwaitsecs headroom and that at-least-once delivery still requires idempotency
- • "A job keeps failing and clogging the queue — what's your move?" — read queue:failed for the exception, stop the storm (queue:forget / raise backoff or retryUntil), fix root cause, queue:retry, and require a finite retry budget in code review
- • "How do you know the queue is healthy?" — three numbers: wait time, throughput, depth, plus liveness via supervisorctl; explain why rising wait time at stable depth is the first alarm, and how you'd scale (numprocs / Horizon balances / per-queue priority)
Cheat Sheet
Running and Monitoring Workers
; supervisor
[program:worker-emails]
command=php /var/www/app/artisan queue:work redis --queue=emails,default --sleep=3 --tries=3 --max-time=3600 --memory=128
numprocs=4
autorestart=true
startsecs=5
stopwaitsecs=650 ; > your slowest job, or SIGKILL severs it
stopsignal=TERM ; finish current job, then exit
stopasgroup=true / killasgroup=true
Deploy: php artisan queue:restart · php artisan horizon:terminate · confirm supervisorctl status.
Retry contract: job $tries beats worker --tries; $backoff = [10,60,300]; retryUntil() deadline; failed() for cleanup; queue:failed → queue:retry <id|all> → queue:forget.
Watch:
| Metric | Command | Alarm means |
|---|---|---|
| wait time | Horizon dashboard | producers outpace workers |
| depth | redis-cli LLEN queues:default |
backlog growing |
| monitor | queue:monitor redis:emails --max=10 |
hot queue over threshold |
| liveness | supervisorctl status |
BACKOFF/FATAL = capacity lost |
Schedule horizon:snapshot every five minutes for metrics history.