[13.x] Add queue concurrency driver - #61273
Draft
mathiasgrimm wants to merge 20 commits into
Draft
Conversation
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>
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
marked this pull request as draft
September 7, 2026 20:42
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
This was referenced Sep 10, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds a
queuedriver to the Concurrency component. It is the same blockingConcurrency::run()API, but the closures run on your queue workers instead of local processes, and the results come back to the caller: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:
It only needs what every Laravel app already has, a queue and a cache. No new tables, no migrations, no new services.
Why
The
processandforkdrivers 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 thesync,process, andqueuedrivers. 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.Notes
failed_jobs/ Horizon.processand the existingconcurrency.driverconfig keeps working through a fallback. The newconcurrency.driversmap also allows named instances likeConcurrency::driver('reports').run()from a worker that consumes the same queue, it can starve until the timeout. Use a dedicated queue or spare capacity.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
failoverconnection, 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
failoverconnection tries each of its connections in turn and treats any error from a connection as that connection being down. Thesyncconnection runs the job right away, inside that call. So if a task threw an error while running on asyncfallback, 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:[redis, sync][redis, sync, sync]sync[redis, sync, database]databasequeue[redis, sync, dead]What changed:
syncconnection 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.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 samesyncrule and is otherwise a normal queued closure.Separately,
QueueDriver::wait()was callingmany()on the cache repository, but that method is only on the concrete cache class, not on theRepositorycontract the method is typed against. A custom cache repository that implements only the contract made the driver fail. It now usesgetMultiple(), which is on the contract.Things to know
defer()sendsInvokeDeferredClosurerather thanCallQueuedClosuredirectly (it still is aCallQueuedClosure).RetrievingManyKeyscache event sees the keys the waygetMultiple()reports them, not the waymany()did.syncconnection throughdefer()is now reported rather than marked as a failed job. On a real queue the worker still records the failure.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.databasestill needs a worker running ondatabase.Every check has a test that fails when the check is removed.
🤖 Generated with Claude Code