Queue Topology
Queue Topology
Before you configure autoscaling, it helps to be precise about which worker listens to which queue. This page explains the models you have available and when to use each.
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.
That distinction — one process polling several queues, versus one pool per queue — is what the choice between worker groups and per-queue workers comes down to.
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.
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 the group configuration enforces (all throw InvalidConfigurationException, from GroupConfiguration):
- The group must declare at least one queue.
- A queue may appear in
queuesor in a group'squeueslist — never both (assertNoQueueConflicts()). - A queue may appear in at most one group (
assertNoQueueConflicts()). modemust be'priority'. It is the only supported value, and the only mode that actually shares a worker across queues.- A group may not list the same queue twice.
- A group cannot use a non-scalable profile — pointing it at
ExclusiveProfile(or any profile withworkers.scalable = false) is rejected. Use a per-queue exclusive config instead.
These are checked on the manager's first evaluation cycle, not at command startup. If a conflict is found, the manager logs Group configuration is invalid — groups disabled until manager restart at critical level and keeps running with all groups disabled; per-queue autoscaling is unaffected. Fix the config and restart the manager.
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:
- Another supervisor 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 a session — 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 supervisor already 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) | ✅ | ❌ |
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.