Skip to content

[13.x] Add queue concurrency driver - #61273

Draft
mathiasgrimm wants to merge 20 commits into
laravel:13.xfrom
mathiasgrimm:queue-concurrency-driver
Draft

[13.x] Add queue concurrency driver#61273
mathiasgrimm wants to merge 20 commits into
laravel:13.xfrom
mathiasgrimm:queue-concurrency-driver

Conversation

@mathiasgrimm

@mathiasgrimm mathiasgrimm commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds a queue driver to the Concurrency component. It is the same blocking Concurrency::run() API, but the closures run on your queue workers instead of local processes, and the results come back to the caller:

$results = Concurrency::driver('queue')->run([
    'thumbnail' => fn () => ProcessImage::thumbnail($path),
    'preview' => fn () => ProcessImage::preview($path),
    'optimized' => fn () => ProcessImage::optimize($path),
]);

Same contract as the other drivers: it blocks, keys and order are preserved, each task returns its value, and when a task throws, the exception is rebuilt and rethrown in the caller.

You can also pick the connection, queue or result store at runtime:

Concurrency::driver('queue')
    ->onConnection('redis')
    ->onQueue('images')
    ->run([...]);

It only needs what every Laravel app already has, a queue and a cache. No new tables, no migrations, no new services.

Why

The process and fork drivers can only use the machine they run on, so the calling instance has to be big enough for the work it spawns. With the queue driver a small (RAM/CPU) web instance does not need to resize images or build exports by spawning PHP processes on itself. The web tier stays small and the heavy work runs on workers sized for it, in parallel, so the response time is close to the slowest task instead of the sum of all of them.

This combines very well with Laravel Cloud managed queues. Flex workers scale to zero while the queue is idle and wake in under a second when jobs arrive, and they are billed per second only while running. So you get real cost savings (no oversized web instances, no always-on workers) together with a performance win: a burst of tasks is absorbed by workers that exist only for the seconds they are needed.

It also makes it possible to keep some endpoints synchronous where today you would build an async flow (submit, poll a status endpoint, wait for a webhook) just because the work is too heavy for the web node. With sub-second worker wake up, the endpoint can dispatch, block for the result and return it in the same response. The wait is bounded (60 seconds by default), so work the client should not wait for still belongs in a normal job.

Demo

There is a demo application at https://github.com/mathiasgrimm/concurrency-demo with a browser UI for every scenario: the parallel run, the sync contrast, exception propagation, timeouts, defer(), and a benchmark of the same three tasks on the sync, process, and queue drivers. Each demo shows the route code and the JSON response side by side, and the readme covers running it locally and deploying it to Laravel Cloud with managed queues.

The queue driver fanning three tasks out to queue workers

Notes

  • Each task is one queued job; the result (or the exception) travels back through a shared cache store, reusing the process driver's result envelope. Task failures also stay visible in failed_jobs / Horizon.
  • No breaking changes: the default driver is still process and the existing concurrency.driver config keeps working through a fallback. The new concurrency.drivers map also allows named instances like Concurrency::driver('reports').
  • Job batches are not used because they cannot carry return values back to a blocking caller.
  • Do not call run() from a worker that consumes the same queue, it can starve until the timeout. Use a dedicated queue or spare capacity.
  • Covered by unit and integration tests, including a real database queue worker round trip; all existing Concurrency tests pass unchanged.

Docs PR to follow if there is interest. Happy to adjust naming or the config shape.


Failover support and safer retries (added after the first review)

These later commits make the driver behave correctly when the queue connection is a failover connection, and when the cache uses a custom store. That is more than the title promises, so I am happy to split it into a separate PR if you would rather review it on its own.

Some background. A failover connection tries each of its connections in turn and treats any error from a connection as that connection being down. The sync connection runs the job right away, inside that call. So if a task threw an error while running on a sync fallback, the failover connection thought the connection was down and ran the task again on the next one. With the primary connection down and a task that throws, this is what happened before the change:

connections task ran the caller got
[redis, sync] once the wrong exception type
[redis, sync, sync] twice the task ran again on the second sync
[redis, sync, database] once, plus a leftover job on the database queue the task's exception
[redis, sync, dead] once the connection error, not the task's own exception
a connection that lists itself crash the process ran out of memory

What changed:

  • When a job runs on the sync connection and the task throws, the job now reports the error and returns instead of throwing. The failover connection no longer mistakes the task's failure for the connection being down.
  • A job checks whether its result is already stored and returns early if it is, so the same task is never run twice.
  • After a run finishes, its cancel flag is kept in the cache for a short while, so a job delivered again after the caller already has its answer skips itself instead of running.
  • Whether the tasks run in the current request is decided by looking at every connection in a failover list. A list that includes a connection whose jobs would never run (null, deferred, background), an empty list, and a list that points back to itself are all rejected before any job is sent. Failover cache stores are checked the same way.
  • defer() now sends its own job class, which follows the same sync rule and is otherwise a normal queued closure.

Separately, QueueDriver::wait() was calling many() on the cache repository, but that method is only on the concrete cache class, not on the Repository contract the method is typed against. A custom cache repository that implements only the contract made the driver fail. It now uses getMultiple(), which is on the contract.

Things to know

  • Two existing tests change on purpose. The cancel flag now stays in the cache after a run (that is the flag a redelivered job checks), and defer() sends InvokeDeferredClosure rather than CallQueuedClosure directly (it still is a CallQueuedClosure).
  • Code listening to the RetrievingManyKeys cache event sees the keys the way getMultiple() reports them, not the way many() did.
  • A task that fails on the sync connection through defer() is now reported rather than marked as a failed job. On a real queue the worker still records the failure.
  • When a failover connection falls through to sync, the tasks run one after another in the current request instead of in parallel. That is what failover is for, and the driver cannot tell in advance which connection will take the job.
  • Failover decides where a job is sent, not where it runs later, so a task that ends up on database still needs a worker running on database.
  • With a failover cache store, a result written to the backup store while the main store is down cannot be read once the main store comes back, so the run times out. Point the driver at a single shared store to avoid this.
  • The skip checks are best effort, not a guarantee. A cache that can return stale reads, or two workers picking up the same job at once, can still run a task twice. As with any queue, a task that must run once should be safe to run twice.

Every check has a test that fails when the check is removed.

🤖 Generated with Claude Code

mathiasgrimm and others added 9 commits August 20, 2026 17:35
Adds Concurrency::driver('queue'), which distributes tasks to queue
workers as individual jobs and transports each task's result back to
the blocking caller through a shared cache store.

Each task is dispatched as an InvokeQueuedClosure job that writes a
success or failure envelope (shared with the process driver via the
new TaskResult helper) to the configured cache store using add(), so
the first terminal write wins under at-least-once delivery. The caller
polls the store with a fakeable Sleep-based loop until every envelope
is present or the total wall-clock timeout elapses, then unwraps the
results in their original key order, rethrowing the first failing
task's reconstructed exception exactly like the process driver.

Task failures on asynchronous connections are rethrown as a
CapturedTaskException wrapper after the envelope is written so they
remain visible to failed job tooling, while a failed() hook covers
managed infrastructure failures. Timeouts write a cancellation flag
that not-yet-started jobs honor, and process-local cache stores and
deferred/null/background queue connections are rejected up front.

The connection, queue, and result store may also be chosen at runtime
via onConnection(), onQueue(), and store(), which return cloned driver
instances so the manager's cached instance is never mutated.

Also introduces a concurrency.drivers config map with a backwards
compatible fallback in getInstanceConfig(), enabling safely named
driver instances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A run whose task throws must still delete its result envelopes and
cancellation key, and the inline missing envelope error must delete
the envelopes it already collected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All three reviewers mutation tested the new cleanup assertions: the
dispatch failure test now runs a first task inline so its cached
envelope proves the catch block deletion, and the tautological
cancellation assertion on the task failure path is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The run method keeps the narrative (guards, context, dispatch, collect,
resolve) while the job assembly loop moves to dispatchTasks, which also
owns the deadline it alone uses, and the unwrap plus cleanup moves to
resolveResults. Behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No framework wire format carries a schema version; payloads evolve
through tolerant key reads, which unwrap already does for parameters.
Nothing read the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The constructor docblocks on the exceptions and the job carried no
information beyond their typed promoted parameters. The wrapper
exception's rationale moved to the class docblock where it belongs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Substituting the word default for a null name pointed away from the
actual problem, a default connection or store that resolved to nothing.
Framework messages interpolate the raw value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Framework and Symfony timeout messages use the plural regardless of
the count; the grammar ternary had no precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The store sharing framing spoke of this process, which no framework
message does; the workers writing results to the store is the actual
mechanism and covers both likely causes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mathiasgrimm and others added 5 commits August 20, 2026 18:50
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The result and cancellation keys passed to the job already embed the
run's ULID, so nothing read the identifier after job tags were removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a sync connection task overran the timeout, the not-yet-started
tasks honored the deadline and skipped their envelopes, so the caller
received the missing envelope error advising a configuration check
even though the configuration was fine. The inline path now throws the
same timeout exception as the polling path when the deadline passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mathiasgrimm and others added 3 commits September 10, 2026 06:12
A failover queue connection tries each link until one accepts the job, and
treats any exception from a link as that link being dead. A sync link runs
the job inside the push, so a task that failed there read as a dead link:
with [redis, sync, sync] and redis down the task ran twice, with
[redis, sync, database] a duplicate job landed on the database queue, with
[redis, sync, dead] the caller received the transport exception instead of
the task's own, and a cyclic chain overflowed the stack inside FailoverQueue.

- InvokeQueuedClosure no longer rethrows when it finds itself running on a
  SyncJob. It envelopes the failure and reports it, as plain sync already
  did, so a failover queue never reads a task failure as a dead link.
- It returns without running when its envelope already exists, so a
  redelivered or re-pushed job cannot run the task a second time.
- A finished run leaves its cancellation flag as a tombstone, written before
  the envelopes are deleted, so a straggler picked up after the caller was
  answered refuses to run. The timeout path already wrote that flag; the two
  now mean the same thing to a worker.
- Whether a run is inline is decided by resolving a failover chain link by
  link, so a chain made only of sync links behaves like sync. Chains that
  contain a connection which would never run the tasks, empty chains, and
  chains that refer back to themselves are refused before dispatch. Failover
  cache stores are checked the same way, since the stock one falls back to
  the array store, and a cyclic one is refused too.
- Envelopes are read back after every dispatch, not only inline ones, and
  seed the poll loop, so a slow later task cannot outlive an earlier
  envelope's lifetime.
- defer() dispatches InvokeDeferredClosure, a CallQueuedClosure that
  overrides only handle() to apply the same SyncJob rule. Everything else a
  deferred closure could observe is inherited: the type it may be hinted on,
  the batch API, failure callbacks, the display name, the worker deciding
  retries, and a closure whose models are gone being discarded.

Two existing assertions change: the cancellation key now remains after a
run, and defer() dispatches InvokeDeferredClosure rather than
CallQueuedClosure directly. The new test class covers every chain above with
a link whose driver does not exist standing in for a dead redis, pins that
the only failover hop is off that link, and covers the tombstone, the
envelope check, every refusal, and defer() on both a synchronous link and a
real database worker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCX8ojX5KisCSMHqcdKUu8
QueueDriver::wait() is typed against Illuminate\Contracts\Cache\Repository
but called many() on it, which only Illuminate\Cache\Repository provides.
Every first-party store is wrapped in that class, so it never surfaced, but
a repository that implements exactly the contract by delegation, the shape
of a tracing or metrics decorator, is a legal return from Cache::store() and
made the asynchronous path fail with "Call to undefined method ::many()"
after the jobs had already been queued.

Both reads now use getMultiple(), which the contract promises through
PSR-16. The first is materialised with iterator_to_array(), because PSR-16
only promises an iterable and a strict implementation may return a
generator, which the in_array() and array_keys() calls that follow cannot
take; on the framework's own repository it is a copy of an array. The poll
loop iterates the result directly, since foreach accepts either.

On first-party stores the results are identical: same keys, order, nulls for
missing keys, and the same CacheHit and CacheMissed events. One thing
changes for anyone listening to RetrievingManyKeys: getMultiple() reports
$keys as an associative [key => default] map rather than a list, and the
inherited $key as an empty string. That is getMultiple()'s existing
behaviour, not something introduced here.

The new tests bind such a decorator, returning arrays and generators, and
cover both the pre-collected and the polling paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCX8ojX5KisCSMHqcdKUu8
The prefer-lowest jobs resolved psr/simple-cache 1.x, whose interface is
untyped, and the test fixture declared the typed 3.x signatures. Narrowing a
parameter type in an implementation is a fatal at class declaration, which
took the whole PHPUnit process down. The fixture now uses untyped parameters
with typed returns, the shape Illuminate\Cache\Repository uses to satisfy
psr/simple-cache 1, 2 and 3 alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCX8ojX5KisCSMHqcdKUu8
…the deferred job

Three findings from the review of the previous commits, each measured.

The deferred retry test was green for the wrong reason. defer() also
registers its callback in the application's deferred callback collection,
and the JobAttempted listener invokes pending callbacks after a successful
attempt. Had the deferred job swallowed its failure on a worker, the first
attempt would count as a success, the listener would dispatch a fresh job,
and every assertion would still hold through that second job rather than a
retry. The test now records the queue's own attempt counter from inside the
task and asserts the second execution saw attempt two, which only a retry
of the same job can produce.

The rule that a failover chain is inline only when every link is had no
test: treating a chain as inline when any link is left the suite green. A
[sync, database] chain with a process-local store is now refused, since the
database worker could never read the store.

InvokeDeferredClosure inherited create() from CallQueuedClosure, whose "new
self" returned the parent class and dropped the synchronous link rule. It
now returns the job itself. A failing deferred task on the sync connection
is also pinned: it is reported and the later tasks still run, the way they
do on a real queue, where before the first failure escaped the callback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCX8ojX5KisCSMHqcdKUu8
…defer()

The delta review found two mutations of resolvesInline() the suite still
did not distinguish from the rule it ships: deciding by the last link only,
and counting a nested failover link as inline without walking it. Under
either, [database, sync] or outer=[inner=[database]] with a process-local
store is accepted, the job lands on the database queue, and the caller
fails at once while the job waits for a worker with no store to read. Two
tests of the same shape as the existing one refuse both.

defer() now builds its job through InvokeDeferredClosure::create(), so the
override that returns the right class is used by the driver rather than
only pinned by a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCX8ojX5KisCSMHqcdKUu8
Rewrites the comments added for the failover handling in plainer words and a
terser style, and drops the jargon. Comments only; no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCX8ojX5KisCSMHqcdKUu8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant