Queue Topology
Queue Topology
Before you configure autoscaling, it helps to be precise about which worker listens to which queue. This page explains the three models you have available, when to use each, and how they compare to Laravel's native queue:work and Horizon.
Read this once; it removes 90% of the confusion about worker behaviour.
The Three Questions You Actually Care About
For any queue in your system, you must answer:
- Does a worker for this queue share its process with another queue? (dedicated vs. shared)
- If shared, who gets priority when both have jobs? (order matters)
- Should the autoscaler manage it at all? (some queues must stay manual)
This package supports all three answers cleanly:
| Answer | Configuration | Section |
|---|---|---|
| Dedicated worker per queue, autoscaled | queues.{name} |
Per-Queue Workers |
| Shared worker across several queues, priority order | groups.{name} |
Worker Groups |
| Exactly one pinned worker, sequential jobs | ExclusiveProfile |
Exclusive (Sequential) Queues |
| Not managed by this package at all | excluded |
Excluded Queues |
How Laravel's queue:work Actually Behaves
When you run php artisan queue:work redis --queue=critical,default,low, Laravel does not drain critical before moving to default. It does this on every poll cycle:
poll() {
if critical has a job → take it, return
if default has a job → take it, return
if low has a job → take it, return
sleep
}
This is strict priority, checked per-poll. Two consequences:
- A burst on
defaultstill gets processed as long as there are gaps incritical's arrivals. - A queue that constantly produces jobs faster than the worker can drain will starve later queues in the list.
This is exactly what Horizon calls balance: false. It is the only Horizon mode where a single worker polls multiple queues — in balance: simple and balance: auto, Horizon spawns one process per queue and just decides how many to run for each.
That subtle distinction is the whole reason this package exists in per-queue form by default.
Per-Queue Workers (Default)
'queues' => [
'payments' => CriticalProfile::class,
'emails' => BalancedProfile::class,
],
What runs: one set of workers per queue, each invoking queue:work redis --queue=payments (or --queue=emails).
What you get:
- Starvation is impossible. Each queue has its own worker pool, its own SLA target, its own metrics.
- Scaling is isolated: a spike on
emailsnever pulls workers away frompayments. - The forecasting, pickup-time percentiles, and spawn-compensation maths all operate cleanly per queue.
What you pay:
- More idle capacity. A quiet queue still holds its
workers.minfloor. Ifemailsis idle whilepaymentsis drowning,emails's workers cannot help. - Spawn latency on bursts. When
paymentssuddenly needs more workers, we predict, forecast, and spawn — which takes seconds. A shared worker would have absorbed the spike immediately.
Use this when: queues have different job durations, different SLA targets, or different failure characteristics. This is the right default for the vast majority of queues.
This model is approximately equivalent to Horizon's
balance: auto, but with a smarter scaling engine (Little's Law, p95 pickup times, trend forecasting) than Horizon'stime/sizestrategies.
Worker Groups
'groups' => [
'notifications' => [
'queues' => ['email', 'sms', 'push'], // priority order
'profile' => BalancedProfile::class,
'connection' => 'redis',
],
],
What runs: one set of workers for the group, each invoking queue:work redis --queue=email,sms,push. The autoscaler treats the group as a single scaling unit — one worker count, one SLA target, one set of metrics aggregated across members.
What you get:
- Zero-latency burst absorption. When
pushsuddenly gets 1000 jobs andemailis quiet, the existing workers immediately pick uppushjobs. No spawning needed. - Shared budget. You set
min/maxfor the group as a whole. You don't pay for idle capacity on each member queue separately. - Priority ordering. Queues listed first get served first per-poll.
What you pay:
- Starvation risk. If
emailconstantly outpaces drain capacity,smsandpushcan starve. Size the group correctly or rely on the autoscaler to add capacity under pressure. - Group-level signal only. Pickup-time percentiles (p95) are computed across the union of all members' samples, and
oldest_job_ageuses the worst member. You lose per-queue precision — if one member is slow and three are fast, the group's p95 reflects the blend, not the slow queue's own SLA. - All members share one SLA target. Not suitable for mixing
payments(10s SLA) withanalytics(hour-long SLA).
Rules enforced at startup:
- A queue may appear in
queuesor in a group'squeueslist — never both. - A queue may appear in at most one group.
- Only
mode: 'priority'is supported (the only mode that actually shares a worker across queues). - Groups cannot use
ExclusiveProfile(a pinned group makes no sense — use a per-queue exclusive config).
Use this when: several queues are closely related (same failure domain, similar job duration, correlated traffic), you want burst absorption, and per-queue SLA precision is not worth the resource cost.
What's Different About Group Scaling
A group's ScalingDecision is computed from aggregated metrics:
pending,scheduled,reserved,throughput: summed across members.oldest_job_age: max across members (the worst case drives the SLA signal).avg_duration: throughput-weighted mean.
This is deliberately conservative. The autoscaler will spawn extra workers if any member is behind — it will not let one happy queue mask another queue's SLA breach.
Exclusive (Sequential) Queues
'queues' => [
'legacy-integration' => ExclusiveProfile::class,
],
What runs: exactly one worker, always. No more, no less.
When the worker dies (OOM, crashed job, operator kill), the manager respawns it on the next evaluation cycle. The package acts as a process supervisor for this queue — not an autoscaler.
What you get:
- Sequential job processing. Two jobs from this queue will never run at the same time.
- Strict ordering under the queue driver's guarantees. Redis FIFO stays FIFO.
- Visibility. SLA breach events still fire, even though we cannot scale to fix them — operators need to know when a single-threaded queue falls behind.
What you pay:
- No autoscaling. Ever. If the queue backs up, workers do not spawn.
- No forecasting. SLA breach detection still works, but prediction is disabled (
DisabledForecastPolicy).
Use this when: a customer integration requires ordered processing, a third-party API enforces single-connection semantics, or your business logic assumes no two jobs from this queue run simultaneously.
If you need "exactly N pinned workers" for some N > 1, write a custom profile that sets
min == max == Nwithscalable: false. The constructor enforces both invariants.
Excluded Queues
'excluded' => [
'horizon-managed',
'legacy-*',
'test-?',
],
What runs: nothing managed by this package. The queue is completely ignored.
Why you need this:
- Horizon is already managing it. Two autoscalers on one queue will fight each other.
- A sidecar tool runs it. Custom supervisord, systemd timers, manual
queue:workin screen — all valid. - It's a throwaway queue for a migration or test, and you do not want the autoscaler inventing a
BalancedProfileworker pool for it.
Glob support: patterns use fnmatch() semantics. legacy-* matches legacy-sync, legacy-reports, etc. test-? matches test-1 but not test-12.
First-time log message: when the manager sees an excluded queue in the metrics stream, it logs a single info line so you can confirm the exclusion is doing what you expect.
Decision Tree
Use this when deciding where a new queue should live.
Is another tool (Horizon, custom) managing it?
├── Yes → add it to 'excluded'
└── No
│
Must jobs run one-at-a-time in strict order?
├── Yes → 'queues' => ExclusiveProfile::class
└── No
│
Does it share a traffic pattern and job-duration profile with other queues?
├── Yes → put them together in 'groups'
└── No → 'queues' => <the profile that matches its SLA>
Comparison Table
| Capability | Per-Queue | Group | Exclusive | Excluded |
|---|---|---|---|---|
| Autoscales | ✅ | ✅ | ❌ | ❌ |
| Dedicated worker process | ✅ | ❌ (shared) | ✅ | n/a |
| Forecast-driven scaling | ✅ | ✅ (aggregate) | ❌ | n/a |
| Burst absorption without spawn | ❌ | ✅ | ❌ | n/a |
| Per-queue SLA precision | ✅ | ❌ (group-level) | ✅ (observability only) | n/a |
| Starvation possible | ❌ | ⚠️ Yes, if sized too small | ❌ | n/a |
| Respawns on worker death | ✅ | ✅ | ✅ | ❌ |
| SLA breach events | ✅ | ✅ (group) | ✅ | ❌ |
Comparison With Horizon
| This package | Horizon equivalent | Notes |
|---|---|---|
| Per-queue (default) | balance: auto |
Both: one pool per queue, dynamic scaling. Our scaling math is more sophisticated (Little's Law + forecasting vs. time/size). |
Group (mode: 'priority') |
balance: false |
Both: one worker process, multiple queues, strict priority per poll. |
| Exclusive profile | balance: false, fixed processes |
Horizon has no built-in "pin to N" — you'd mimic it with minProcesses = maxProcesses = 1. Our profile makes it a first-class declaration with supervisor-style respawn. |
| Excluded | n/a | Horizon does not have a "leave this queue alone" concept; you either manage a queue or you don't. |
Horizon has balance: simple (fixed processes split evenly across queues). We deliberately do not expose this: it's strictly worse than either per-queue (better scaling) or groups (better resource sharing), and offering it would invite confusion.
Common Mistakes
"I want workers grouped by SLA." — This sounds intuitive but is the wrong primitive. SLA is a target, not a scheduling axis. Two queues with the same 30s SLA but wildly different job durations (50ms vs. 8s) will behave terribly if mixed: the fast queue's workers get stuck on slow jobs. Group by job characteristics and correlation, then verify the SLA targets are compatible.
"I'll put payments and analytics in one group to save workers." — Don't. payments needs a 10s SLA, analytics can wait an hour. In a group they share one SLA target, and you'll either over-provision for analytics or under-provision for payments. Use per-queue for different SLA regimes.
"The group should auto-detect its members from queue names." — Auto-detection is a footgun. A new queue named email-followup-v2 should not silently join the email group just because of its prefix. All group membership is declared explicitly.
"Excluded queues will still show up in metrics, right?" — Yes. The metrics package keeps recording them. excluded only affects this package — nothing else. That's the whole point: observe, don't manage.