Every Laravel deployment I have seen has a line like this somewhere:
[program:queue-worker]
numprocs=10
Why 10? Nobody knows. Someone picked it years ago and it stayed. Too low and
jobs wait minutes during a campaign blast. Too high and you burn RAM all night
for workers doing nothing. The number is wrong in both directions because load
changes and the config does not.
I spent this week testing an alternative on a demo app:
cboxdk/laravel-queue-autoscale
with cboxdk/laravel-queue-metrics,
built by Sylvester Damgaard, who worked on the queue manager at Laravel. Instead
of a worker count, you declare a promise, and the count is derived. These are my
measured results.
The formula
You declare one thing, an SLA: "a job on this queue must be picked up within
10 seconds." Then every 5 seconds a manager process solves:
workers = (pending jobs / SLA seconds) / jobs per second per worker
Read it in two steps. pending / SLA seconds is the throughput you need: 3,000
pending jobs with a 10s promise means you must process 300 jobs per second.
Divide by what one worker can do (my demo job takes ~100ms, so ~10 jobs/s) and
you get 30 workers. Same reasoning as "I need to move 300 boxes per hour, one
person moves 10, so I need 30 people."
This is Little's Law from queueing theory, the same 1961 theorem behind checkout
lines and CPU schedulers. The insight is that the only input a human provides
is a business question (how long may a job wait?) instead of a technical guess
(how many processes?).
If you have ever sized a PHP-FPM pool with
max_children = RAM / avg process size, this is the identical idea. Measure
one unit, derive the count. Web requests spend memory, queue jobs spend time.
Setup
composer require cboxdk/laravel-queue-autoscale cboxdk/laravel-queue-metrics
php artisan vendor:publish --tag=queue-autoscale-config
// config/queue-autoscale.php
'queues' => [
'orders' => [
'connection' => 'redis',
'sla' => ['target_seconds' => 10],
'workers' => ['min' => 1, 'max' => 16],
],
],
Then run php artisan queue:autoscale. It spawns and kills plain
php artisan queue:work processes, the same command you would run by hand. The
workers do not know the autoscaler exists.
Two gotchas that cost me time: the manager needs ext-pcntl (and v4 also
ext-posix), and each queue entry needs an explicit 'connection', otherwise
it silently looks for a queue connection literally named default and every
evaluation fails into the log.
Measured: 3,000 jobs hit the queue
I dispatched 3,000 jobs of ~100ms at once against the 10s SLA and sampled every
5 seconds:
| t | workers | pending |
|---|---|---|
| 0s | 1 | 3,000 |
| 5s | 6 | 2,910 |
| 10s | 10 | 2,619 |
| 15s | 14 | 2,209 |
| 25s | 16 (cap) | 956 |
| 35s | 16 | 0 |
The formula asked for 30 workers; my configured cap of 16 won (the package also
caps by measured host CPU and RAM, so it will not scale past what the machine
carries). All 3,000 jobs completed, none lost, and on SIGTERM every worker
finished its in-flight job before dying. Scale-down afterwards is deliberately
slow, one worker per cycle with an anti-flapping cooldown, so a bursty queue
does not yo-yo.
The part that impressed me: an outage is not load
Here is the trap with any naive autoscaler. Your payment provider dies. Every
job fails and retries. The backlog grows. The math screams "add workers!" and
now 16 workers are hammering a dead API, which helps nobody.
I simulated it: 2,000 jobs that all throw. The manager logged this:
fuse OPEN: 100.0% failure rate over 28 jobs, holding at workers.min instead
of scaling into the failure; backlog=1999 requires 999.5 workers to prevent
SLA breach
The backlog math demanded 999.5 workers. The failure fuse allowed 1. It
detects that the failure rate (not the backlog) is the real signal, pins the
queue at minimum, and uses that one worker as a probe. When my jobs stopped
failing, the failure window aged out, the fuse closed itself (0% over 387
jobs) and normal scaling resumed. No restart, no human.
That single log line, "999.5 demanded, 1 allowed", is the difference between
an autoscaler and a bash script.
Do 16 workers on one queue conflict?
No, and it is worth understanding why. When a worker claims a job, Laravel runs
a Lua script in redis that pops the job from the pending list AND writes it to
a reserved set as one atomic operation. Redis runs Lua single-threaded, so two
workers can never claim the same job.
But the guarantee is at-least-once, not exactly-once. Reservations have a
timeout (retry_after, default 90s): if a worker dies mid-job, the job returns
to pending and runs again. So the rules stay the same as always: make jobs
idempotent, keep retry_after longer than your slowest job, and reach for
ShouldBeUnique / WithoutOverlapping when the business logic needs it.
What about Horizon?
Different question. Horizon balances workers ACROSS queues, but the pool size
is still maxProcesses, a number you pick. The autoscaler derives the number
itself. They are not competitors; the gap in both worlds was never balancing,
it was that someone still picks the number.
Honest tradeoffs
- It scales processes on one host, not machines. Multi-host needs its cluster mode; on Kubernetes you might prefer scaling pods.
- Young project, one maintainer. I found a real bug during testing (the metrics package's Prometheus endpoint reads keys its own DTO does not write).
- The manager itself needs supervising (systemd or a container restart policy), and it should run continuously: a freshly restarted manager has no measurements and ramps lazily for a minute while it re-learns.
- v4 needs PHP 8.4+, ext-pcntl and ext-posix. No Windows dev machines.
The takeaway
Worker counts, like FPM pool sizes, are derivable numbers that we have been
configuring by folklore. Declare the promise, measure the unit, let the math
run every 5 seconds. My queue now answers a question my config never could:
"how many workers do I need right now?"
Exactly as many as the backlog says. And when everything is on fire, exactly
one.
Top comments (0)