Skip to content

[13.x] Add debounceable queued jobs#59507

Open
matthewnessworthy wants to merge 9 commits intolaravel:13.xfrom
matthewnessworthy:feature/debounce-jobs
Open

[13.x] Add debounceable queued jobs#59507
matthewnessworthy wants to merge 9 commits intolaravel:13.xfrom
matthewnessworthy:feature/debounce-jobs

Conversation

@matthewnessworthy
Copy link
Copy Markdown
Contributor

@matthewnessworthy matthewnessworthy commented Apr 2, 2026

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 ShouldBeDebounced job is dispatched, PendingDispatch::__destruct() calls acquireDebounceLock() 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->debounceOwner and survives serialization through the queue. The job's delay is set to the debounceFor value. 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 a JobDebounced event 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)

DebounceLock uses plain cache put/get/forget operations 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:

  • Dispatch: $cache->put($key, $token, $ttl) — overwrites any existing token
  • Execute: $cache->get($key) === $token — checks if this dispatch is still the latest
  • Cleanup: $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 existing ShouldBeUnique pattern), preventing stale tokens from blocking future dispatches.

Middleware Event Parity

The Debounced middleware fires the JobDebounced event when a job is superseded, matching the behavior of the ShouldBeDebounced interface path.

Transaction Safety

Both Queue::enqueueUsing() and SyncQueue::push() register rollback callbacks to release the debounce token (owner-aware) if a database transaction rolls back — mirroring the existing ShouldBeUnique pattern.

Comparison with ShouldBeUnique

Behavior ShouldBeUnique ShouldBeDebounced
Which dispatch wins? First Last
Subsequent dispatches Rejected (not queued) Queued, but supersede earlier ones
Token stored at Dispatch time Dispatch time (overwrites previous)
Checked at Dispatch time (prevents queueing) Execution time (skips if superseded)
Removed at After execution After execution (owner-aware)
Missing token behavior N/A Fail-open (job executes)
Storage mechanism Cache locks Plain cache key
Manager UniqueLock DebounceLock
Use case Prevent duplicate work Ensure only final state is processed

Usage

Interface Approach

Implement ShouldBeDebounced and define debounceFor() to set the debounce window:

use Illuminate\Contracts\Queue\ShouldBeDebounced;
use Illuminate\Contracts\Queue\ShouldQueue;

class RebuildSearchIndex implements ShouldQueue, ShouldBeDebounced
{
    public string $debounceOwner = '';

    public function __construct(
        public int $documentId,
    ) {}

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

    public function debounceFor(): int
    {
        return 30; // seconds
    }

    public function handle(): void
    {
        // Only the last dispatch within 30s actually runs
        SearchIndex::rebuild($this->documentId);
    }
}

Then dispatch as normal — debounce is automatic:

// User edits document multiple times rapidly
RebuildSearchIndex::dispatch($document->id); // superseded
RebuildSearchIndex::dispatch($document->id); // superseded
RebuildSearchIndex::dispatch($document->id); // this one executes

PHP Attribute

Use the #[DebounceFor] attribute instead of a method:

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

#[DebounceFor(debounceFor: 30)]
class RebuildSearchIndex implements ShouldQueue, ShouldBeDebounced
{
    // debounceFor() method not needed — attribute provides the value
}

The debounceFor() method takes precedence over the attribute if both are present.

Middleware Approach

For jobs that don't implement the interface, use the Debounced middleware directly:

use Illuminate\Queue\Middleware\Debounced;

class ProcessWebhook implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            new Debounced(key: 'webhook-' . $this->webhookId, debounceFor: 60),
        ];
    }
}

The middleware delegates to DebounceLock internally, so key format and behavior are identical to the interface approach. The middleware also fires JobDebounced when a job is superseded.

Custom Cache Store

Override the cache store used for debounce tokens:

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

Listening for Superseded Jobs

Listen for the JobDebounced event to track when jobs are skipped:

use Illuminate\Queue\Events\JobDebounced;

Event::listen(function (JobDebounced $event) {
    Log::info('Job debounced', [
        'connection' => $event->connectionName,
        'job' => get_class($event->command),
    ]);
});

Mutual Exclusivity

ShouldBeDebounced and ShouldBeUnique are mutually exclusive. Implementing both on the same job throws a LogicException at 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 configuration
  • Illuminate\Queue\Events\JobDebounced — Event fired when a superseded job is skipped
  • Illuminate\Queue\Middleware\Debounced — Standalone middleware that delegates to DebounceLock

Modified Files

  • Illuminate\Foundation\Bus\PendingDispatchacquireDebounceLock() called in __destruct(), stores cache key/owner in context
  • Illuminate\Queue\CallQueuedHandlercommandWasDebounced() with fail-open check, owner-aware token release, chain/batch handling on debounce, context-based release for model-not-found
  • Illuminate\Queue\Queue — Owner-aware debounce token rollback in enqueueUsing()
  • Illuminate\Queue\SyncQueue — Owner-aware debounce token rollback in push()

Tests

12 integration tests covering:

  • Dispatch and execution
  • Supersession (last wins)
  • Token release after success and failure
  • JobDebounced event firing
  • Mutual exclusivity with ShouldBeUnique
  • Serialization round-trip of debounceOwner
  • Different debounceId isolation
  • Cache key format
  • Queue::fake() compatibility
  • Fail-open when cache token is evicted
  • Owner-aware release prevents wiping a newer dispatch's token

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>
@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 and others added 2 commits April 2, 2026 20:47
- 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>
matthewnessworthy and others added 6 commits April 2, 2026 21:23
- 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>
@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

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.

2 participants