Index

A Worker Count Is Not a Throughput Plan

Four Laravel queue workers complete 114 waiting jobs per second where one worker completes 37. Keep the worker count at four, make every job update the same database row, and the rate falls to 32. Make each job fail twice before succeeding, and those four workers perform 540 attempts to produce 180 effects.

Nothing is wrong with the worker setting. It is answering three different workloads.

A worker count describes available executors. It says nothing about whether a job can run in parallel, which dependency must absorb the concurrency, or how many attempts are needed for one business effect. A throughput plan needs those parts too.

The queue row is not the business result

Suppose an API accepts 60 catalogue projections. The queue now contains 60 messages, but the application still needs 60 distinct results. Between those two counts sit reservations, attempts, releases, exceptions, and exhausted jobs.

That gives us three useful units:

  • a logical operation is the accepted request the application owes;
  • an attempt is one execution of a queued job; and
  • an effect is the durable application result produced at most once for that operation.

The synthetic LAR-03 fixture stores those units separately. An operation row owns status and a stable identifier. A projection row owns the completed effect. Redis owns available and reserved queue work. Append-only event ledgers retain what each worker did.

Every successful profile must finish with 60 accepted operations, 60 successful statuses, 60 projections, no failed jobs, and an empty queue. A run that loses an effect cannot win because it ended sooner.

This distinction becomes essential around retries. Sixty operations that each need three attempts are not 180 completed jobs in any useful business sense. They are 180 attempts, 120 rejected executions, and 60 effects.

The same worker pool serves different constraints

The fixture runs four controlled job shapes through one, two, and four real queue:work processes:

ShapeWhat each job doesLikely constraint
WaitingWaits without holding the application databaseIndependent latency
CPUPerforms a fixed deterministic hash workloadProcess computation
Hot rowHolds a transaction while updating one rowSerialized database ownership
RetryingRejects attempts one and two, then writes onceAttempt amplification

Redis 8.2.1 coordinates the queue. A separate SQLite database in WAL mode owns the application state and the deliberate hot row. This split matters. Using SQLite for both queue reservation and application effects would mix broker contention into the result we are trying to observe.

Each main profile contains three independent trials of 60 preloaded jobs. Job policy, payload distribution, timeout, retry policy, and final projection stay the same while worker count changes. Setup and dispatch remain outside the worker completion window.

The retained median logical completion rates are local observations, not portable targets:

ShapeOne workerTwo workersFour workers
Waiting37.3/s67.3/s113.9/s
CPU92.6/s114.6/s147.7/s
Hot row37.4/s37.2/s32.2/s
Retrying59.0 effects/s111.0 effects/s147.0 effects/s

The waiting case rewards concurrency because one worker can progress while another waits. The hot-row case does not: SQLite permits concurrent readers in WAL mode, but writes are still serialized. Four workers compete for the same declared write owner and finish fewer logical operations per second than one worker in this run.

The CPU result is deliberately less satisfying. More workers improved the local rate, but the experiment did not pin a container CPU quota or establish host headroom. PHP’s getrusage() can report process user and system time; it cannot tell us what production cores, neighbors, or downstream dependencies will do. The proper next experiment is controlled concurrency under a declared CPU limit, not “use four.”

A drained batch can hide an unstable queue

Preloading 60 jobs answers a narrow question: how quickly did this pool drain a finite batch? It does not show whether the pool keeps pace with continuing arrivals.

The fixture therefore repeats the waiting shape with one job dispatched every 15 milliseconds, a declared arrival rate of 66.7 operations per second. One worker completes roughly 42 per second in the three trials. Queue depth reaches 18 or 19, and the oldest unfinished operation reaches 0.41 to 0.47 seconds before dispatch stops.

Two workers complete about 62 per second over the full dispatch-to-finish window. Their maximum sampled depth is two, and oldest unfinished age remains around 0.02 seconds. All 60 operations eventually finish in both cases.

If we looked only at final depth, both pools would appear healthy: zero is a lovely number after the producer has gone home. The useful timeline keeps arrival, successful completion, depth, and age together.

That still does not prove that two workers sustainably serve 66.7 arrivals per second. The experiment is short, its completion window includes dispatch, and the host is uncontrolled. A capacity test needs a representative arrival distribution, a relevant observation window, and an objective for acceptable age. The finite run shows the direction of pressure, not a production limit.

Retry throughput can flatter a failing system

The retrying shape throws on its first two attempts and creates its projection on the third. Every worker count ends with:

60 logical operations
180 attempts
120 rejected attempts
60 successful effects
0 duplicate effects

An attempt-rate graph will rise as workers execute those failures faster. That may be useful if the failure is transient and the next attempt has a measured reason to succeed. It may also increase pressure on the dependency causing the failure.

Laravel separates the worker timeout from the queue connection’s retry_after setting. Its queue documentation warns that the worker timeout should be several seconds shorter than retry_after; otherwise a job may be retried before its previous process has stopped. The fixture retains a two-second worker timeout and ten-second reservation expiry, then rejects the inverse as a semantic mutation.

Timeout ordering limits overlapping execution. It does not make an effect idempotent. The operation identifier and unique projection are what prevent a retry from multiplying the business result.

An isolated exhausted job proves the other end of the contract. After three rejections, the queue is empty, the failed-job store has one entry, the application operation says failed, and no projection exists. Queue deletion, failed-job storage, application status, and completed effect are four related facts. None can substitute for the others.

More workers can move the bottleneck downstream

The hot-row case is intentionally crude: every job updates one catalogue generation inside a transaction. It makes a hidden assumption visible. Worker parallelism helps only while shared dependencies retain headroom for the new concurrency.

The dependency might be a database writer, an external API rate limit, a Redis connection pool, a filesystem, or a lock protecting ordered work. Adding workers can increase queue reservation and worker utilization while successful effects remain flat. In a retry storm it can also increase failure volume.

This does not make one worker universally safer. Independent waiting work may benefit immediately, as the fixture shows. The decision record needs to name the dependency and the evidence that it can accept another concurrent job.

For a serialized job, reducing lock scope, partitioning ownership, or changing the job boundary is a credible alternative. For a long mixed workload, isolating queues may protect latency-sensitive work. For CPU work, improving the algorithm may be cheaper than multiplying processes. Worker count comes after those questions, not before them.

A long-lived worker keeps more than code alive

Laravel queue workers are long-lived processes. They do not reboot the framework between jobs, and they do not notice deployed code until they are restarted. That lifecycle affects memory, static state, database connections, and deployment.

The fixture includes a labelled synthetic control which retains 256 KiB in process-static state after each job. One worker handles 30 jobs in one generation. Observed memory rises from about 27.3 MiB to 35.7 MiB. Repeating the same 30 jobs as three ten-job worker generations resets the process; each generation ends around 29.4 MiB.

PHP’s memory_get_usage() and memory_get_peak_usage() expose those process observations. They do not prove a Laravel leak, and recycling does not repair the allocation which caused growth. It only bounds retained process state in this controlled case.

Laravel provides --max-jobs, --max-time, and memory limits as worker stop conditions. A supervisor can replace the exited process. Choosing those values requires the application’s memory trend, job duration, restart cost, remaining queue work, and failure behavior.

Deployment has a similar boundary. Activating new files does not alter code already loaded by a worker. Laravel’s queue:restart asks workers to exit gracefully after their current job, using the cache to carry the restart signal. Whatever supervisor runs the workers must then start replacements. Code activation and worker replacement are two steps, and both belong in the deployment contract.

Dashboards observe the plan; they do not supply it

Laravel Horizon provides throughput, runtime, failure, wait, and worker controls for Redis queues. Its balancing strategies can allocate workers across queues. Laravel Pulse can relate slow jobs and queues to application and server observations, subject to its sampling and storage configuration.

Those are useful operational surfaces. Neither can decide what counts as one business effect, whether a retry is safe, or whether a hot database row owns the constraint. Application status and effect identity still belong to the application. Database contention still needs database evidence.

A practical worker decision can stay score-free:

Observed shapeNext experimentAnother worker?
Independent waitIncrease concurrency while watching the dependencyPlausible with headroom
CPU workProfile under a declared CPU limitPlausible with idle CPU
Serialized writeReduce lock scope or partition ownershipUnsupported before repair
Repeated failureClassify failure; verify backoff and idempotencyMay amplify pressure

Keep the condition that reverses each choice. Arrival may fall below the current service rate. A dependency may gain capacity. A job may be split. A retry policy, queue backend, database topology, or service objective may change. Then repeat the same profile rather than defending yesterday’s worker number.

The reproduction retains 2,160 main logical jobs, 360 paced jobs, 45 raw event ledgers, 20 rejected counterfactuals, 11 primary-source responses, four score-free decisions, and a clean export of commit 9456fb8aa2dbf439116e2fe092eb71afe364747c. It proves the behavior of its pinned Redis queue, SQLite application store, synthetic jobs, and observed host. It does not prove Brian’s workload, production capacity, optimal worker count, service objective, memory policy, or database topology.

Choose a worker count only after the plan names arrival, logical effects, job shape, shared-dependency headroom, retry amplification, and process lifecycle. Until then, the number controls how many unknown constraints the system can hit at once.