Skip to content
LaravelBlog

laravel/framework: Unique job locks are now released by Queue::fakeFor()

LaravelBlogBot
LaravelBlogBot

Contributed by xurshudyan

Part of the framework v13.35.0 release

Testing unique jobs with Laravel's scoped queue fakes could quietly leave stale locks in the cache — until this release. Pull request #61824 fixes Queue::fakeFor() and Queue::fakeExceptFor() so that unique job locks acquired inside the callback are released when the callback finishes. If you are upgrading from v13.34.0, this is a pure bug fix: there are no API changes to adopt, but tests that dispatch unique jobs across fake scopes behave correctly now, and you can delete any manual cache-cleanup workarounds you added to dodge the leak.

Unique jobs and the leaking lock

Jobs implementing Illuminate\Contracts\Queue\ShouldBeUnique acquire a cache lock the moment they are dispatched. The lock is keyed by the job class (plus uniqueId() when defined) and held for uniqueFor() seconds — indefinitely if uniqueFor is not set. In production, a worker releases the lock after processing the job. Under Queue::fake(), jobs never run, so an earlier fix (#58718) introduced QueueFake::releaseUniqueJobLocks() to clean up when faking again.

The scoped helpers were never wired into that cleanup. Queue::fakeFor() swaps in a QueueFake for the duration of the callback and swaps the original queue manager back afterwards, but it never told the fake to release the locks its dispatched unique jobs had taken. The failure mode looks like this:

Queue::fakeFor(function () {
    ImportUsers::dispatch();
});

ImportUsers::dispatch(); // silently skipped — the lock is still held

The second dispatch is rejected by the still-live cache lock, so the job is never pushed to the real queue. Any downstream assertion — Queue::assertPushed(), a database row, an email — simply doesn't happen. With no uniqueFor() configured, the lock never expires, so one leaky test can poison later tests in the same process until the cache is flushed.

What this pull request changes

The fix mirrors the Queue::fake() treatment. Both fakeFor() and fakeExceptFor() now capture the QueueFake instance and call releaseUniqueJobLocks() inside the existing finally block, right before the original queue manager is swapped back:

$fake = static::fake($jobsToFake);

try {
    return $callable();
} finally {
    $fake->releaseUniqueJobLocks();

    static::swap($originalQueueManager);
}

Placing the release in finally matters: even if an assertion inside the callback throws, the locks are released and the manager is restored, so a failing test can no longer leak state into the tests that follow.

Two things are deliberately unchanged. First, deduplication inside the fake scope still works — while the callback runs, the lock is held, so dispatching the same unique job twice within one fakeFor() block still records only the first push. Second, QueueFake::releaseUniqueJobLocks() now returns early when no unique jobs were pushed, so a fake that never touched a unique job never resolves the cache repository or constructs a UniqueLock at all. That keeps the re-fake path from #58718 a cheap no-op in the common case.

Seeing the difference in a test

The regression tests added with this PR verify that the lock is acquirable again after the callback completes:

public function test_queue_fake_for_releases_unique_job_locks(): void
{
    Queue::fakeFor(function () {
        UniqueTestJob::dispatch();
        Queue::assertPushed(UniqueTestJob::class);
    });

    $this->assertTrue(
        $this->app->get(Cache::class)
            ->lock($this->getLockKey(UniqueTestJob::class), 10)
            ->get()
    );
}

In application tests the practical effect is simpler: after fakeFor() returns, the cache no longer holds a laravel_unique_job:* entry for the faked dispatch, so you can dispatch the same unique job again — against the real queue or a subsequent Queue::fake() — and it is accepted instead of skipped. Equivalent tests for fakeExceptFor() were added alongside, so both scoped helpers share the same guarantee.

Upgrade impact

Coming from v13.34.0 there is nothing to migrate. The facade method signatures, QueueFake, and the lock key format are untouched; only the cleanup at the end of the fake scope is new. In practice you may want to:

  • remove Cache::flush() calls or targeted cache cleanups from tearDown() that existed purely to clear leaked laravel_unique_job:* keys;
  • revisit tests that failed intermittently after dispatching the same unique job in multiple fake scopes — they should now pass deterministically;
  • review any test that asserted a unique lock was still held after a fakeFor() callback ended. That behavior contradicted the production semantics of unique jobs, and if you happened to depend on it, those assertions will need updating.

If your suite currently passes, it will keep passing — the fix only removes lock state that nothing else could observe or legitimately use.

Takeaways

  • Queue::fakeFor() and Queue::fakeExceptFor() now call releaseUniqueJobLocks() in a finally block when the callback finishes, extending the Queue::fake() fix from #58718 to the scoped helpers.
  • Locks are released even when the callback throws, and the original queue manager is still swapped back.
  • Unique-job deduplication within a single fake scope is unchanged; only locks held after the scope closes are cleared.
  • releaseUniqueJobLocks() bails out early when no unique jobs were pushed, avoiding an unnecessary cache resolution.
  • No API changes — a safe patch-level upgrade; drop any manual cache-cleanup workarounds for leaked unique locks.

Sources

More from this release

Related Articles