Running on Amazon SQS
Running on Amazon SQS
The autoscaler works on SQS like on any other driver: the manager reads queue depth through
laravel-queue-metrics, spawns queue:work sqs workers, and terminates them when the backlog is
gone. Two things differ from Redis, and both decide how well it scales:
- SQS cannot report the age of its oldest message. The pickup-time SLA — for scaling and for
SlaBreached/SlaRecoveredalike — has to come from pickup samples, and on SQS those need two settings that are off by default. - Workers see a queue URL, not a queue name. The package reduces it back to the name, which is what you configure — so keep your queue names free of the connection's suffix.
Version 4.4 or later is assumed throughout. Before 4.4, pickup samples and failure-fuse outcomes on
SQS were recorded under the queue URL and never reached the manager. On 4.4, samples reached the manager
and drove scaling, but SlaBreached and SlaRecovered still could not fire on SQS; that needs 4.5.
The minimal setup
// config/queue.php
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.eu-west-1.amazonaws.com/123456789012'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'eu-west-1'),
],
// config/queue-autoscale.php
'queues' => [
'emails' => [
'connection' => 'sqs',
'sla' => ['target_seconds' => 60],
'workers' => ['min' => 1, 'max' => 20],
],
],
# .env
QUEUE_CONNECTION=sqs
# Pickup-time samples: stamp pushedAt and keep the samples in Redis
QUEUE_AUTOSCALE_STAMP_PUSHED_AT=true
QUEUE_AUTOSCALE_PICKUP_TIME_STORE=redis
The queue key in queue-autoscale.queues is the queue name — the same string you dispatch to
and that queue:work --queue= receives — never the URL. The connection key tells the manager to
read that queue's metrics from, and spawn its workers on, the sqs connection.
Why pickup samples matter on SQS
The SLA is a pickup-time target: how long a job waits between becoming available and a worker starting it. The strategy reads it from two sources, in order:
- The p95 of recent pickup samples, recorded by every worker as it starts a job.
- The oldest pending job's age, read from the queue driver.
SQS has no API for the second — Laravel's SqsQueue::creationTimeOfOldestPendingJob() returns
null — so on SQS the samples are the only SLA signal. Without them:
- Worker targets come from the arrival rate (Little's Law) and the current backlog drained within the SLA window. Nothing measures how long jobs are actually waiting, so there is no urgency as they approach the target.
SlaBreachedandSlaRecoverednever fire. Breach is judged on the same signal the strategy scales on — the pickup percentile when there are enough samples, the oldest job's age otherwise — and on SQS that age is always 0.SlaBreachPredictedstill fires: its prediction is the time to work through the backlog at the target worker count, which needs no job age.
With samples flowing, from 4.5 SlaBreached fires when the p95 pickup time reaches the queue's
sla.target_seconds, and SlaRecovered when the p95 over sla.window_seconds is back under it.
The events' oldestJobAge / currentJobAge stay 0 on SQS; read $event->slaSignal or
$event->signalSeconds() for the pickup time the breach was decided on. See
How breach is decided for the window and
recovery semantics.
One limit follows from SQS having no oldest-job age. On Redis and the database driver, a queue
whose workers stall breaches about one SLA target after its last pickup, judged on how long the job
at its head has waited. SQS cannot say how long that job has waited, and a stall records no new
pickups, so a breach shows up only through the samples recorded before and after it. Alert on ApproximateAgeOfOldestMessage in
CloudWatch as well if you need to catch a stall on SQS.
A sample needs two things, and queue:autoscale:doctor warns about every SQS queue missing either
(SQS queues have no SLA signal):
pushedAtin the job payload. Horizon writes it, but Horizon only runs on Redis, so on SQS setQUEUE_AUTOSCALE_STAMP_PUSHED_AT=true. The package then stampspushedAton every job dispatched from the application. Stamping is safe with the requiredlaravel-queue-metrics3.4.1, which measures job duration from when processing starts, not frompushedAt.- A shared store. Samples are recorded inside the worker processes and read by the manager.
The default store,
auto, is Redis in cluster mode and a no-op on a single host, so a single host needsQUEUE_AUTOSCALE_PICKUP_TIME_STORE=redis. Redis is used only for these samples; the jobs stay on SQS.
Only a job's first attempt is sampled, a delayed job is measured from when its delay ends, and
samples above pickup_time.max_sample_seconds (default 3600) are dropped. Keep that cap well above
your SLA targets; queue:autoscale:doctor warns when it is not. See
Where pickup time comes from.
To check that samples arrive, run the manager with -vv while the queue has a backlog. When the
backlog drives the decision, its reason names the SLA signal it used: p95=… once enough samples
exist (sla.min_samples), oldest_age=0.0s while they do not.
php artisan queue:autoscale -vv
Queue names, URLs and the suffix
Laravel's SQS driver builds a queue URL from the connection's prefix, the queue name and the
suffix, and hands workers that full URL as the job's queue. Dispatching, queue:work --queue, the
autoscaler's configuration and the metrics package all use the name, so anything recorded inside a
worker — pickup samples and failure-fuse outcomes — is first reduced back to it:
- A queue value starting with
http://orhttps://(any case) is treated as a URL. Anything else is already a name and is left alone. - The last segment of the URL's path is taken. A URL with no path segment is left as it is.
- If the connection has a
suffix, it is removed from the end of the name. SQS FIFO names keep their.fifo, and the suffix is removed from before it, the way Laravel adds it.
Connection suffix |
Queue URL a worker sees | Recorded as |
|---|---|---|
| none | https://sqs.eu-west-1.amazonaws.com/123456789012/emails |
emails |
-production |
https://sqs.eu-west-1.amazonaws.com/123456789012/emails-production |
emails |
-production |
https://sqs.eu-west-1.amazonaws.com/123456789012/orders-production.fifo |
orders.fifo |
-production |
https://sqs.eu-west-1.amazonaws.com/123456789012/-production |
-production (a name that is only the suffix is kept) |
| none | http://local_stack:4566/000000000000/emails |
emails |
laravel-queue-metrics 3.4.1 applies the same rule, so depth, throughput, pickup samples and fuse
outcomes all meet under one name, and excluded patterns match SQS queues by name.
Do not include the suffix in the queue name itself. Laravel only appends the suffix when the
name does not already end with it, so a queue configured as emails-production on a connection
with the suffix -production is sent to …/emails-production — and recorded as emails, which is
not the name the manager reads. Configure and dispatch to emails, and let the suffix do its job.
SQS only accepts letters, digits, hyphens and underscores in a queue name, plus a .fifo ending.
A dotted name such as tenant.42 works on Redis and fails at dispatch on SQS;
queue:autoscale:doctor flags it when an SQS connection is configured.
FIFO queues
FIFO queues scale like standard ones, with two differences:
- A FIFO queue releases at most one message per message group at a time, so workers beyond the
number of active groups only poll.
queue:autoscale:doctornotes FIFO queues whoseworkers.maxallows more than one worker. See Scaling FIFO Queues. - SQS does not accept a per-message delay on a FIFO queue, so Laravel dispatches delayed FIFO jobs
without one. Pickup is still measured from
pushedAt.
Configure the queue under its full name, .fifo included:
'queues' => [
'orders.fifo' => [
'connection' => 'sqs',
'workers' => ['min' => 1, 'max' => 5],
],
],
Depth is approximate
The backlog the default strategy works from is SQS's ApproximateNumberOfMessages, which AWS
describes as approximate and eventually consistent. Small queues can read a message or two off for a short while, which can delay
a scale-to-zero by a cycle. Messages in flight (received but not deleted) are not part of the
backlog, so a long visibility timeout does not inflate it.
LocalStack and ElasticMQ
Point the connection's prefix at the emulator. Hosts with underscores, such as a Docker Compose
service named local_stack, are recognised as queue URLs from 4.4:
'sqs' => [
'driver' => 'sqs',
'key' => 'test',
'secret' => 'test',
'prefix' => env('SQS_PREFIX', 'http://local_stack:4566/000000000000'),
'queue' => 'default',
'region' => 'us-east-1',
'endpoint' => env('SQS_ENDPOINT', 'http://local_stack:4566'),
],
The package's own SQS specs run against ElasticMQ; see Requirements for the container they use.
Workers that hold long-running jobs
On SQS a job that runs longer than the queue's visibility timeout is delivered again to another
worker. Keep workers.timeout_seconds (the --timeout passed to queue:work) below the queue's
visibility timeout. A job that exceeds --timeout is SIGKILLed by queue:work itself and shows up
as a WorkerExited with signal 9 — see
Troubleshooting.
Checklist
- Queue keys in
queue-autoscale.queuesare queue names with'connection' => 'sqs' - No queue name already ends with the connection's
suffix -
QUEUE_AUTOSCALE_STAMP_PUSHED_AT=true -
QUEUE_AUTOSCALE_PICKUP_TIME_STORE=redison a single host (cluster mode uses Redis already) -
workers.timeout_secondsbelow each queue's visibility timeout -
php artisan queue:autoscale:doctorrun, with no SQS name, SLA-signal or pickup-cap warnings