Skip to content

[13.x] Add debounceable queued jobs#59507

Merged
taylorotwell merged 19 commits intolaravel:13.xfrom
matthewnessworthy:feature/debounce-jobs
Apr 15, 2026
Merged

[13.x] Add debounceable queued jobs#59507
taylorotwell merged 19 commits intolaravel:13.xfrom
matthewnessworthy:feature/debounce-jobs

Conversation

@matthewnessworthy
Copy link
Copy Markdown
Contributor

@matthewnessworthy matthewnessworthy commented Apr 2, 2026

Summary

Adds debouncing for queued jobs — when the same job is dispatched multiple times within a time window, only the last dispatch executes. Earlier dispatches are silently discarded at execution time.

Apply the #[DebounceFor] attribute to any ShouldQueue job, or debounce at the dispatch site. No interface needed.

How it works

  1. Dispatch: A random owner token is written to the cache (last-writer-wins). The job is delayed by the debounce window.
  2. Execute: The worker checks if the job's token still matches the cache. If yes, it runs. If a newer dispatch overwrote the token, the job is deleted and a JobDebounced event fires.
  3. Fail-open: If the cache entry is missing (eviction, cache:clear), the job executes rather than being silently lost.

Uses plain cache put/get — not locks. Locks enforce mutual exclusion (first-writer-wins), which is the opposite of what debounce needs.

Comparison with ShouldBeUnique

ShouldBeUnique #[DebounceFor]
Which dispatch wins? First Last
Rejected dispatches Not queued Queued but skipped at execution
Check point Dispatch time Execution time
Storage Cache locks Plain cache key
Missing token N/A Fail-open (job runs)
Manager UniqueLock DebounceLock

Combining both on the same job throws LogicException — first-wins and last-wins are mutually exclusive.

Usage

Attribute-based (on the job class)

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\DebounceFor;

#[DebounceFor(30)]
class RebuildSearchIndex implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(public int $documentId) {}

    public function debounceId(): string
    {
        return (string) $this->documentId;
    }

    public function handle(): void
    {
        SearchIndex::rebuild($this->documentId);
    }
}
// User edits a document 10 times in 30 seconds — only the last dispatch runs
RebuildSearchIndex::dispatch($document->id);

Call-site debouncing

Debounce any job at the dispatch site without modifying the job class:

// Debounce a plain job — no attribute needed on the class
dispatch(new SyncExternalData($accountId))->debounceFor(30);

// Override the class attribute at dispatch time
dispatch(new RebuildSearchIndex($docId))->debounceFor(10);

Max-wait cap

Prevent a frequently re-dispatched job from being deferred indefinitely. After maxWait seconds from the first dispatch, the job executes immediately:

#[DebounceFor(30, maxWait: 120)]
class SyncDashboard implements ShouldQueue { ... }

// Or at the dispatch site:
dispatch(new SyncDashboard)->debounceFor(30)->maxDebounceWait(120);

Override delay via method

The debounceFor() method takes precedence over the attribute value:

#[DebounceFor(30)]
class RebuildSearchIndex implements ShouldQueue
{
    public function debounceFor(): int
    {
        return $this->priority === 'high' ? 5 : 30;
    }
}

Custom cache store

public function debounceVia(): \Illuminate\Contracts\Cache\Repository
{
    return Cache::store('redis');
}

Listen for superseded jobs

Event::listen(JobDebounced::class, function (JobDebounced $event) {
    Log::info('Debounced: '.get_class($event->command));
});

Design notes

  • Owner-aware release: DebounceLock::release() only removes the token if it still belongs to the caller, preventing a finished job from wiping a newer dispatch's token.
  • Superseded jobs are fully discarded: A superseded job fires JobDebounced and is deleted — it does not dispatch chain links or record batch success, since no work was performed.
  • Transaction rollback: Debounce tokens are released on DB transaction rollback, matching the ShouldBeUnique pattern.
  • Cache TTL: Set to 10× the debounce window (min 300s) for garbage collection only — correctness doesn't depend on it.
  • Priority: call-site ->debounceFor() > debounceFor() method > #[DebounceFor] attribute.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

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.

@matthewnessworthy matthewnessworthy changed the title Add debounceable queued jobs [13.x] Add debounceable queued jobs Apr 2, 2026
@matthewnessworthy matthewnessworthy marked this pull request as ready for review April 2, 2026 20:28
@bert-w
Copy link
Copy Markdown
Contributor

bert-w commented Apr 5, 2026

Good stuff. The only thing i dont like is the empty interface and the method_exists checks, but perhaps those are inherent to the way Laravel jobs are written. It makes it non-intuitive to fill in functions and I have to either check the code or read the docs.

@matthewnessworthy
Copy link
Copy Markdown
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 ShouldBeUnique

@matthewnessworthy
Copy link
Copy Markdown
Contributor Author

@taylorotwell any thoughts on this? happy to make adjustments as needed

@kohlerdominik
Copy link
Copy Markdown
Contributor

Something that I missed, and I didn't even know it. I would love to see something like this added to the Framework.

I just noticed in ShouldBeUniqueUntilProcessing that retries affect the lock in a potential unpleasant way and tried a fix with #59567. In your test cases, I also see testing only for $tries = 1. Did you implement a strategy for retries, and would it possibly also fit ShouldBeUniqueUntilProcessing?

@matthewnessworthy
Copy link
Copy Markdown
Contributor Author

@kohlerdominik retries shouldn't be a problem, if it gets bounced back into the queue it will re-check whether it is still the owner of this job and either continue processing or abort (if it's no longer the owner)
I don't think Debounceable sufferers from the same problem as ShouldBeUniqueUntilProcessing as it's using get/set on the cache instead of lock. Additionally, the issue you are describing is almost an inherent flaw because as soon as the processing starts it is no longer unique

@taylorotwell
Copy link
Copy Markdown
Member

I feel like there is too much going on here. Interface, attribute, middleware - I'm not sure which one to use. Just have an attribute imo.

@taylorotwell taylorotwell marked this pull request as draft April 9, 2026 14:23
@matthewnessworthy matthewnessworthy force-pushed the feature/debounce-jobs branch 3 times, most recently from 9495bb4 to a2c715e Compare April 9, 2026 15:46
@matthewnessworthy matthewnessworthy marked this pull request as ready for review April 9, 2026 15:56
@matthewnessworthy
Copy link
Copy Markdown
Contributor Author

@taylorotwell No problem, PR updated with your review, should be significantly simpler now

matthewnessworthy and others added 14 commits April 13, 2026 12:51
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>
- 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>
- 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>
Simplify to attribute-only approach per maintainer feedback.
The #[DebounceFor] attribute is now the sole mechanism for
enabling job debouncing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move debounceOwner property to Queueable trait to avoid PHP 8.2+
dynamic property deprecations, extract getDebounceDelay() on
DebounceLock to eliminate duplicated resolution logic in
PendingDispatch, and fix stale comment about token lifecycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Avoid resolving Cache from the container when the job has no debounce
configuration, fixing BindingResolutionException in test environments
without a cache store bound.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
matthewnessworthy and others added 5 commits April 13, 2026 13:54
…ehavior

- dispatch(new Job)->debounceFor(30) enables debouncing at the call site
- #[DebounceFor(30, maxWait: 120)] caps how long a job can be deferred
- Superseded jobs no longer trigger chain dispatch or batch success recording
- Add test coverage for debounceVia() custom cache store
@taylorotwell taylorotwell merged commit 09cb37d into laravel:13.x Apr 15, 2026
39 of 56 checks passed
@matthewnessworthy matthewnessworthy deleted the feature/debounce-jobs branch April 15, 2026 14:53
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.

4 participants