[13.x] Add debounceable queued jobs#59507
Open
matthewnessworthy wants to merge 9 commits intolaravel:13.xfrom
Open
[13.x] Add debounceable queued jobs#59507matthewnessworthy wants to merge 9 commits intolaravel:13.xfrom
matthewnessworthy wants to merge 9 commits intolaravel:13.xfrom
Conversation
Adds last-dispatch-wins semantics for queued jobs via ShouldBeDebounced interface. When multiple dispatches occur for the same debounce identity, only the most recent executes. New files: - ShouldBeDebounced marker interface - DebounceLock cache-based lock manager - DebounceFor PHP 8 attribute - JobDebounced event - Debounced standalone middleware Framework integration: - PendingDispatch acquires debounce lock at dispatch time - CallQueuedHandler checks ownership at execution time - Queue/SyncQueue register transaction rollback handlers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks for submitting a PR! Note that draft PRs are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface. Pull requests that are abandoned in draft may be closed due to inactivity. |
- Guard ensureDebounceLockIsReleased() call with instanceof ShouldBeDebounced to prevent extra isReleased() calls breaking ThrottlesExceptionsTest mocks - Extend debounce lock TTL to debounceFor*2 so lock remains valid when the delayed job becomes available for processing - Travel past debounce window in DebouncedJobTest before running queue worker so delayed jobs are available on async queue drivers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1f1a4e9 to
02c8246
Compare
- Make release() owner-aware to prevent wiping a newer dispatch's lock - Fail-open when lock is missing (cache eviction/TTL expiry) instead of silently deleting the job - Continue chain/batch dispatch when a debounced job is superseded - Release debounce lock via context on model-not-found exceptions - Fire JobDebounced event from Debounced middleware for parity with the ShouldBeDebounced interface path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The lock TTL must exceed the debounce delay so the ownership check can distinguish superseded jobs from expired locks at execution time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Locks are the wrong abstraction for debounce — we need a "latest owner" marker, not mutual exclusion. This replaces forceRelease/get/restoreLock with simple cache put/get/forget operations. The TTL is now generous (10x debounceFor, min 300s) for garbage collection only — correctness no longer depends on it. A 15-minute debounce window no longer requires a 30-minute lock. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Skip delayed-job tests on Beanstalkd (travelTo cannot control an external server's delay timer) - Replace Event::fake with Event::listen for the superseded event test to avoid subtle interaction issues with queue:work artisan command Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Releasing the token after the current owner executes creates a race: if the current job processes before a superseded one, removing the token causes the superseded job to see an empty cache and execute via fail-open. The token's only purpose is supersession detection. Let the generous GC TTL (min 300s) handle cleanup. Transaction rollback callbacks still release the token when a dispatch is abandoned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Contributor
|
Good stuff. The only thing i dont like is the empty interface and the |
Contributor
Author
|
@bert-w thanks for taking the time to review the code, both points you mentioned are consistent with Laravel and the similar feature |
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
Adds the ability to debounce queued jobs. When multiple dispatches occur for the same debounce identity within a time window, only the most recently dispatched job executes — last dispatch wins.
This is the inverse of
ShouldBeUnique. Where unique jobs protect against duplicate processing by letting the first dispatch win and rejecting subsequent ones, debounced jobs let the last dispatch win, silently discarding earlier dispatches that are no longer relevant.Motivation
Many real-world workflows dispatch jobs on every change — model updates, webhook receipts, search index rebuilds, report generation. Without debounce, each change triggers a separate job, leading to redundant processing. Developers currently work around this with custom delay + cache lock patterns. This feature makes it a first-class framework concern.
Example: A user edits a document 10 times in 30 seconds. Without debounce, 10 index rebuild jobs run. With debounce, only the last one runs — processing the final state.
How It Works
Dispatch Time
When a
ShouldBeDebouncedjob is dispatched,PendingDispatch::__destruct()callsacquireDebounceLock()which stores a random owner token in the cache for this debounce identity (overwriting any previous token — last-writer-wins). The token is stored on the job as$job->debounceOwnerand survives serialization through the queue. The job's delay is set to thedebounceForvalue. The cache key and owner are also stored in the Laravel context for safe cleanup if model deserialization fails.Execution Time
In
CallQueuedHandler::call(), after deserializing the command,commandWasDebounced()checks whether the job's owner token still matches the value stored in cache. If it matches, the job executes normally and the token is removed. If it doesn't match (a newer dispatch overwrote it), the job is deleted and aJobDebouncedevent is fired.If the cache entry no longer exists at execution time (cache eviction,
cache:clear), the job executes (fail-open) rather than being silently discarded. This prevents cache volatility from causing job loss.Cache-Based Design (Not Lock-Based)
DebounceLockuses plain cacheput/get/forgetoperations rather than cache locks. Locks provide mutual exclusion, which debounce explicitly doesn't need — we want last-writer-wins, not first-writer-wins. The cache entry is simply a "latest owner" marker:$cache->put($key, $token, $ttl)— overwrites any existing token$cache->get($key) === $token— checks if this dispatch is still the latest$cache->forget($key)— removes token after execution (only if still owner)The cache TTL is intentionally generous (10x
debounceFor, minimum 300s) and exists purely for garbage collection. Correctness does not depend on it — the token is explicitly removed after the job executes.Owner-Aware Release
DebounceLock::release()accepts an owner token and only removes the cache entry if it still belongs to that owner. This prevents a race condition where Job A finishing execution could wipe Job B's token (stored by a newer dispatch), which would cause Job B to be treated as superseded and silently discarded.Chain and Batch Continuity
When a superseded job is skipped,
handleDebouncedJob()dispatches the next job in the chain and records batch success before deleting — preventing chains from stalling or batches from hanging indefinitely.Model-Not-Found Cleanup
If model deserialization fails (
ModelNotFoundException), the debounce token is released via context (mirroring the existingShouldBeUniquepattern), preventing stale tokens from blocking future dispatches.Middleware Event Parity
The
Debouncedmiddleware fires theJobDebouncedevent when a job is superseded, matching the behavior of theShouldBeDebouncedinterface path.Transaction Safety
Both
Queue::enqueueUsing()andSyncQueue::push()register rollback callbacks to release the debounce token (owner-aware) if a database transaction rolls back — mirroring the existingShouldBeUniquepattern.Comparison with
ShouldBeUniqueShouldBeUniqueShouldBeDebouncedUniqueLockDebounceLockUsage
Interface Approach
Implement
ShouldBeDebouncedand definedebounceFor()to set the debounce window:Then dispatch as normal — debounce is automatic:
PHP Attribute
Use the
#[DebounceFor]attribute instead of a method:The
debounceFor()method takes precedence over the attribute if both are present.Middleware Approach
For jobs that don't implement the interface, use the
Debouncedmiddleware directly:The middleware delegates to
DebounceLockinternally, so key format and behavior are identical to the interface approach. The middleware also firesJobDebouncedwhen a job is superseded.Custom Cache Store
Override the cache store used for debounce tokens:
Listening for Superseded Jobs
Listen for the
JobDebouncedevent to track when jobs are skipped:Mutual Exclusivity
ShouldBeDebouncedandShouldBeUniqueare mutually exclusive. Implementing both on the same job throws aLogicExceptionat dispatch time — first-wins and last-wins semantics cannot coexist.Implementation
New Files
Illuminate\Contracts\Queue\ShouldBeDebounced— Marker interface (no methods)Illuminate\Bus\DebounceLock— Token manager using plain cache operations:acquire(),isCurrentOwner(),lockExists(),release(),getKey()Illuminate\Queue\Attributes\DebounceFor— PHP 8 attribute for debounce window configurationIlluminate\Queue\Events\JobDebounced— Event fired when a superseded job is skippedIlluminate\Queue\Middleware\Debounced— Standalone middleware that delegates toDebounceLockModified Files
Illuminate\Foundation\Bus\PendingDispatch—acquireDebounceLock()called in__destruct(), stores cache key/owner in contextIlluminate\Queue\CallQueuedHandler—commandWasDebounced()with fail-open check, owner-aware token release, chain/batch handling on debounce, context-based release for model-not-foundIlluminate\Queue\Queue— Owner-aware debounce token rollback inenqueueUsing()Illuminate\Queue\SyncQueue— Owner-aware debounce token rollback inpush()Tests
12 integration tests covering:
JobDebouncedevent firingShouldBeUniquedebounceOwnerdebounceIdisolationQueue::fake()compatibility