From 3275977a024b2ff5681654fa92029500ead68f5b Mon Sep 17 00:00:00 2001 From: Henk Koop Date: Wed, 22 Oct 2025 23:09:51 +0200 Subject: [PATCH 1/3] Add jobTypeIdentifier() method for custom job identification --- src/Illuminate/Bus/UniqueLock.php | 6 +- .../Queue/Middleware/ThrottlesExceptions.php | 6 +- .../Queue/Middleware/WithoutOverlapping.php | 12 ++- .../Queue/ThrottlesExceptionsTest.php | 80 +++++++++++++++++ tests/Integration/Queue/UniqueJobTest.php | 86 +++++++++++++++++++ .../Queue/WithoutOverlappingJobsTest.php | 33 +++++++ 6 files changed, 218 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Bus/UniqueLock.php b/src/Illuminate/Bus/UniqueLock.php index c1d74c636f1e..323d118dbcd6 100644 --- a/src/Illuminate/Bus/UniqueLock.php +++ b/src/Illuminate/Bus/UniqueLock.php @@ -69,6 +69,10 @@ public static function getKey($job) ? $job->uniqueId() : ($job->uniqueId ?? ''); - return 'laravel_unique_job:'.get_class($job).':'.$uniqueId; + $jobType = method_exists($job, 'jobTypeIdentifier') + ? $job->jobTypeIdentifier() + : get_class($job); + + return 'laravel_unique_job:'.$jobType.':'.$uniqueId; } } diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php index 307842d2e6de..7272059fbe76 100644 --- a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php @@ -256,7 +256,11 @@ protected function getKey($job) return $this->prefix.$job->job->uuid(); } - return $this->prefix.hash('xxh128', get_class($job)); + $jobType = method_exists($job, 'jobTypeIdentifier') + ? $job->jobTypeIdentifier() + : get_class($job); + + return $this->prefix.hash('xxh128', $jobType); } /** diff --git a/src/Illuminate/Queue/Middleware/WithoutOverlapping.php b/src/Illuminate/Queue/Middleware/WithoutOverlapping.php index 42fabdaa3303..4a530da8eb0d 100644 --- a/src/Illuminate/Queue/Middleware/WithoutOverlapping.php +++ b/src/Illuminate/Queue/Middleware/WithoutOverlapping.php @@ -154,8 +154,14 @@ public function shared() */ public function getLockKey($job) { - return $this->shareKey - ? $this->prefix.$this->key - : $this->prefix.get_class($job).':'.$this->key; + if ($this->shareKey) { + return $this->prefix.$this->key; + } + + $jobType = method_exists($job, 'jobTypeIdentifier') + ? $job->jobTypeIdentifier() + : get_class($job); + + return $this->prefix.$jobType.':'.$this->key; } } diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php index 34667768208b..81d173bee00e 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -5,6 +5,7 @@ use Exception; use Illuminate\Bus\Dispatcher; use Illuminate\Bus\Queueable; +use Illuminate\Cache\RateLimiter; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Queue\Job; use Illuminate\Queue\CallQueuedHandler; @@ -345,6 +346,85 @@ public function release() $middleware->report(fn () => false); $middleware->handle($job, $next); } + + public function testUsesJobClassNameForCacheKey() + { + $rateLimiter = $this->mock(RateLimiter::class); + + $job = new class + { + public $released = false; + + public function release() + { + $this->released = true; + + return $this; + } + }; + + $expectedKey = 'laravel_throttles_exceptions:'.hash('xxh128', get_class($job)); + + $rateLimiter->shouldReceive('tooManyAttempts') + ->once() + ->with($expectedKey, 10) + ->andReturn(false); + + $rateLimiter->shouldReceive('hit') + ->once() + ->with($expectedKey, 600); + + $next = function ($job) { + throw new RuntimeException('Whoops!'); + }; + + $middleware = new ThrottlesExceptions(); + $middleware->handle($job, $next); + + $this->assertTrue($job->released); + } + + public function testUsesJobTypeIdentifierForCacheKeyWhenAvailable() + { + $rateLimiter = $this->mock(RateLimiter::class); + + $job = new class + { + public $released = false; + + public function release() + { + $this->released = true; + + return $this; + } + + public function jobTypeIdentifier(): string + { + return 'App\\Actions\\ThrottlesExceptionsTestAction'; + } + }; + + $expectedKey = 'laravel_throttles_exceptions:'.hash('xxh128', 'App\\Actions\\ThrottlesExceptionsTestAction'); + + $rateLimiter->shouldReceive('tooManyAttempts') + ->once() + ->with($expectedKey, 10) + ->andReturn(false); + + $rateLimiter->shouldReceive('hit') + ->once() + ->with($expectedKey, 600); + + $next = function ($job) { + throw new RuntimeException('Whoops!'); + }; + + $middleware = new ThrottlesExceptions(); + $middleware->handle($job, $next); + + $this->assertTrue($job->released); + } } class CircuitBreakerTestJob diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 5ec58efdec2b..eb3d7acb5ab4 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -4,6 +4,7 @@ use Exception; use Illuminate\Bus\Queueable; +use Illuminate\Bus\UniqueLock; use Illuminate\Container\Container; use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -169,6 +170,62 @@ protected function getLockKey($job) { return 'laravel_unique_job:'.(is_string($job) ? $job : get_class($job)).':'; } + + public function testLockUsesJobTypeIdentifierWhenAvailable() + { + Bus::fake(); + + $lockKey = 'laravel_unique_job:App\\Actions\\UniqueTestAction:'; + + dispatch(new UniqueTestJobWithJobTypeIdentifier); + $this->runQueueWorkerCommand(['--once' => true]); + Bus::assertDispatched(UniqueTestJobWithJobTypeIdentifier::class); + + $this->assertFalse( + $this->app->get(Cache::class)->lock($lockKey, 10)->get() + ); + + Bus::assertDispatchedTimes(UniqueTestJobWithJobTypeIdentifier::class); + dispatch(new UniqueTestJobWithJobTypeIdentifier); + $this->runQueueWorkerCommand(['--once' => true]); + Bus::assertDispatchedTimes(UniqueTestJobWithJobTypeIdentifier::class); + + $this->assertFalse( + $this->app->get(Cache::class)->lock($lockKey, 10)->get() + ); + } + + public function testUniqueLockCreatesKeyWithClassName() + { + $this->assertEquals( + 'laravel_unique_job:'.UniqueTestJob::class.':', + UniqueLock::getKey(new UniqueTestJob) + ); + } + + public function testUniqueLockCreatesKeyWithIdAndClassName() + { + $this->assertEquals( + 'laravel_unique_job:'.UniqueIdTestJob::class.':unique-id-1', + UniqueLock::getKey(new UniqueIdTestJob) + ); + } + + public function testUniqueLockCreatesKeyWithJobTypeIdentifierWhenAvailable() + { + $this->assertEquals( + 'laravel_unique_job:App\\Actions\\UniqueTestAction:unique-id-2', + UniqueLock::getKey(new UniqueIdTestJobWithJobTypeIdentifier) + ); + } + + public function testUniqueLockCreatesKeyWithIdAndJobTypeIdentifierWhenAvailable() + { + $this->assertEquals( + 'laravel_unique_job:App\\Actions\\UniqueTestAction:unique-id-2', + UniqueLock::getKey(new UniqueIdTestJobWithJobTypeIdentifier) + ); + } } class UniqueTestJob implements ShouldQueue, ShouldBeUnique @@ -239,3 +296,32 @@ public function uniqueVia(): Cache return Container::getInstance()->make(Cache::class); } } + +class UniqueIdTestJob extends UniqueTestJob +{ + public function uniqueId(): string + { + return 'unique-id-1'; + } +} + +class UniqueTestJobWithJobTypeIdentifier extends UniqueTestJob +{ + public function jobTypeIdentifier(): string + { + return 'App\\Actions\\UniqueTestAction'; + } +} + +class UniqueIdTestJobWithJobTypeIdentifier extends UniqueTestJob +{ + public function uniqueId(): string + { + return 'unique-id-2'; + } + + public function jobTypeIdentifier(): string + { + return 'App\\Actions\\UniqueTestAction'; + } +} diff --git a/tests/Integration/Queue/WithoutOverlappingJobsTest.php b/tests/Integration/Queue/WithoutOverlappingJobsTest.php index 98eea03acce6..c06d5f691210 100644 --- a/tests/Integration/Queue/WithoutOverlappingJobsTest.php +++ b/tests/Integration/Queue/WithoutOverlappingJobsTest.php @@ -151,6 +151,31 @@ public function testGetLock() (new WithoutOverlapping('key'))->withPrefix('prefix:')->shared()->getLockKey($job) ); } + + public function testGetLockUsesJobTypeIdentifier() + { + $job = new OverlappingTestJobWithJobTypeIdentifier; + + $this->assertSame( + 'laravel-queue-overlap:App\\Actions\\WithoutOverlappingTestAction:key', + (new WithoutOverlapping('key'))->getLockKey($job) + ); + + $this->assertSame( + 'laravel-queue-overlap:key', + (new WithoutOverlapping('key'))->shared()->getLockKey($job) + ); + + $this->assertSame( + 'prefix:App\\Actions\\WithoutOverlappingTestAction:key', + (new WithoutOverlapping('key'))->withPrefix('prefix:')->getLockKey($job) + ); + + $this->assertSame( + 'prefix:key', + (new WithoutOverlapping('key'))->withPrefix('prefix:')->shared()->getLockKey($job) + ); + } } class OverlappingTestJob @@ -221,3 +246,11 @@ public function middleware() return [(new WithoutOverlapping)->shared()]; } } + +class OverlappingTestJobWithJobTypeIdentifier extends OverlappingTestJob +{ + public function jobTypeIdentifier(): string + { + return 'App\\Actions\\WithoutOverlappingTestAction'; + } +} From 02edfc9a73eb9bbf64215cab088089c71d6bf74b Mon Sep 17 00:00:00 2001 From: Henk Koop Date: Fri, 24 Oct 2025 20:12:09 +0200 Subject: [PATCH 2/3] Use displayName() for custom job identification in locks and middleware --- src/Illuminate/Bus/UniqueLock.php | 6 ++-- .../Queue/Middleware/ThrottlesExceptions.php | 6 ++-- .../Queue/Middleware/WithoutOverlapping.php | 6 ++-- .../Queue/ThrottlesExceptionsTest.php | 4 +-- tests/Integration/Queue/UniqueJobTest.php | 28 +++++++++---------- .../Queue/WithoutOverlappingJobsTest.php | 8 +++--- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/Illuminate/Bus/UniqueLock.php b/src/Illuminate/Bus/UniqueLock.php index 323d118dbcd6..df2caf8f81fa 100644 --- a/src/Illuminate/Bus/UniqueLock.php +++ b/src/Illuminate/Bus/UniqueLock.php @@ -69,10 +69,10 @@ public static function getKey($job) ? $job->uniqueId() : ($job->uniqueId ?? ''); - $jobType = method_exists($job, 'jobTypeIdentifier') - ? $job->jobTypeIdentifier() + $jobName = method_exists($job, 'displayName') + ? $job->displayName() : get_class($job); - return 'laravel_unique_job:'.$jobType.':'.$uniqueId; + return 'laravel_unique_job:'.$jobName.':'.$uniqueId; } } diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php index 7272059fbe76..32ea76cbf7db 100644 --- a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php @@ -256,11 +256,11 @@ protected function getKey($job) return $this->prefix.$job->job->uuid(); } - $jobType = method_exists($job, 'jobTypeIdentifier') - ? $job->jobTypeIdentifier() + $jobName = method_exists($job, 'displayName') + ? $job->displayName() : get_class($job); - return $this->prefix.hash('xxh128', $jobType); + return $this->prefix.hash('xxh128', $jobName); } /** diff --git a/src/Illuminate/Queue/Middleware/WithoutOverlapping.php b/src/Illuminate/Queue/Middleware/WithoutOverlapping.php index 4a530da8eb0d..0f9c40680817 100644 --- a/src/Illuminate/Queue/Middleware/WithoutOverlapping.php +++ b/src/Illuminate/Queue/Middleware/WithoutOverlapping.php @@ -158,10 +158,10 @@ public function getLockKey($job) return $this->prefix.$this->key; } - $jobType = method_exists($job, 'jobTypeIdentifier') - ? $job->jobTypeIdentifier() + $jobName = method_exists($job, 'displayName') + ? $job->displayName() : get_class($job); - return $this->prefix.$jobType.':'.$this->key; + return $this->prefix.$jobName.':'.$this->key; } } diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php index 81d173bee00e..74dab53838ff 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -384,7 +384,7 @@ public function release() $this->assertTrue($job->released); } - public function testUsesJobTypeIdentifierForCacheKeyWhenAvailable() + public function testUsesDisplayNameForCacheKeyWhenAvailable() { $rateLimiter = $this->mock(RateLimiter::class); @@ -399,7 +399,7 @@ public function release() return $this; } - public function jobTypeIdentifier(): string + public function displayName(): string { return 'App\\Actions\\ThrottlesExceptionsTestAction'; } diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index eb3d7acb5ab4..f82b525339a9 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -171,24 +171,24 @@ protected function getLockKey($job) return 'laravel_unique_job:'.(is_string($job) ? $job : get_class($job)).':'; } - public function testLockUsesJobTypeIdentifierWhenAvailable() + public function testLockUsesDisplayNameWhenAvailable() { Bus::fake(); $lockKey = 'laravel_unique_job:App\\Actions\\UniqueTestAction:'; - dispatch(new UniqueTestJobWithJobTypeIdentifier); + dispatch(new UniqueTestJobWithDisplayName); $this->runQueueWorkerCommand(['--once' => true]); - Bus::assertDispatched(UniqueTestJobWithJobTypeIdentifier::class); + Bus::assertDispatched(UniqueTestJobWithDisplayName::class); $this->assertFalse( $this->app->get(Cache::class)->lock($lockKey, 10)->get() ); - Bus::assertDispatchedTimes(UniqueTestJobWithJobTypeIdentifier::class); - dispatch(new UniqueTestJobWithJobTypeIdentifier); + Bus::assertDispatchedTimes(UniqueTestJobWithDisplayName::class); + dispatch(new UniqueTestJobWithDisplayName); $this->runQueueWorkerCommand(['--once' => true]); - Bus::assertDispatchedTimes(UniqueTestJobWithJobTypeIdentifier::class); + Bus::assertDispatchedTimes(UniqueTestJobWithDisplayName::class); $this->assertFalse( $this->app->get(Cache::class)->lock($lockKey, 10)->get() @@ -211,19 +211,19 @@ public function testUniqueLockCreatesKeyWithIdAndClassName() ); } - public function testUniqueLockCreatesKeyWithJobTypeIdentifierWhenAvailable() + public function testUniqueLockCreatesKeyWithDisplayNameWhenAvailable() { $this->assertEquals( 'laravel_unique_job:App\\Actions\\UniqueTestAction:unique-id-2', - UniqueLock::getKey(new UniqueIdTestJobWithJobTypeIdentifier) + UniqueLock::getKey(new UniqueIdTestJobWithDisplayName) ); } - public function testUniqueLockCreatesKeyWithIdAndJobTypeIdentifierWhenAvailable() + public function testUniqueLockCreatesKeyWithIdAndDisplayNameWhenAvailable() { $this->assertEquals( 'laravel_unique_job:App\\Actions\\UniqueTestAction:unique-id-2', - UniqueLock::getKey(new UniqueIdTestJobWithJobTypeIdentifier) + UniqueLock::getKey(new UniqueIdTestJobWithDisplayName) ); } } @@ -305,22 +305,22 @@ public function uniqueId(): string } } -class UniqueTestJobWithJobTypeIdentifier extends UniqueTestJob +class UniqueTestJobWithDisplayName extends UniqueTestJob { - public function jobTypeIdentifier(): string + public function displayName(): string { return 'App\\Actions\\UniqueTestAction'; } } -class UniqueIdTestJobWithJobTypeIdentifier extends UniqueTestJob +class UniqueIdTestJobWithDisplayName extends UniqueTestJob { public function uniqueId(): string { return 'unique-id-2'; } - public function jobTypeIdentifier(): string + public function displayName(): string { return 'App\\Actions\\UniqueTestAction'; } diff --git a/tests/Integration/Queue/WithoutOverlappingJobsTest.php b/tests/Integration/Queue/WithoutOverlappingJobsTest.php index c06d5f691210..22d166ec3eeb 100644 --- a/tests/Integration/Queue/WithoutOverlappingJobsTest.php +++ b/tests/Integration/Queue/WithoutOverlappingJobsTest.php @@ -152,9 +152,9 @@ public function testGetLock() ); } - public function testGetLockUsesJobTypeIdentifier() + public function testGetLockUsesDisplayName() { - $job = new OverlappingTestJobWithJobTypeIdentifier; + $job = new OverlappingTestJobWithDisplayName; $this->assertSame( 'laravel-queue-overlap:App\\Actions\\WithoutOverlappingTestAction:key', @@ -247,9 +247,9 @@ public function middleware() } } -class OverlappingTestJobWithJobTypeIdentifier extends OverlappingTestJob +class OverlappingTestJobWithDisplayName extends OverlappingTestJob { - public function jobTypeIdentifier(): string + public function displayName(): string { return 'App\\Actions\\WithoutOverlappingTestAction'; } From 9d517b6a4207febc45273607b029cf0dcb355566 Mon Sep 17 00:00:00 2001 From: Henk Koop Date: Fri, 24 Oct 2025 20:37:47 +0200 Subject: [PATCH 3/3] Update UniqueBroadcastEvent to work with displayName() in lock key --- .../Broadcasting/UniqueBroadcastEvent.php | 2 - .../Broadcasting/BroadcastManagerTest.php | 40 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Broadcasting/UniqueBroadcastEvent.php b/src/Illuminate/Broadcasting/UniqueBroadcastEvent.php index b99af6f843d5..3e1916da45e0 100644 --- a/src/Illuminate/Broadcasting/UniqueBroadcastEvent.php +++ b/src/Illuminate/Broadcasting/UniqueBroadcastEvent.php @@ -29,8 +29,6 @@ class UniqueBroadcastEvent extends BroadcastEvent implements ShouldBeUnique */ public function __construct($event) { - $this->uniqueId = get_class($event); - if (method_exists($event, 'uniqueId')) { $this->uniqueId .= $event->uniqueId(); } elseif (property_exists($event, 'uniqueId')) { diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 485f2757ecda..872c11c680be 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -75,7 +75,35 @@ public function testUniqueEventsCanBeBroadcast() Bus::assertNotDispatched(UniqueBroadcastEvent::class); Queue::assertPushed(UniqueBroadcastEvent::class); - $lockKey = 'laravel_unique_job:'.UniqueBroadcastEvent::class.':'.TestEventUnique::class; + $lockKey = 'laravel_unique_job:'.TestEventUnique::class.':'; + $this->assertFalse($this->app->get(Cache::class)->lock($lockKey, 10)->get()); + } + + public function testUniqueEventsCanBeBroadcastWithUniqueIdFromProperty() + { + Bus::fake(); + Queue::fake(); + + Broadcast::queue(new TestEventUniqueWithIdProperty); + + Bus::assertNotDispatched(UniqueBroadcastEvent::class); + Queue::assertPushed(UniqueBroadcastEvent::class); + + $lockKey = 'laravel_unique_job:'.TestEventUniqueWithIdProperty::class.':unique-id-property'; + $this->assertFalse($this->app->get(Cache::class)->lock($lockKey, 10)->get()); + } + + public function testUniqueEventsCanBeBroadcastWithUniqueIdFromMethod() + { + Bus::fake(); + Queue::fake(); + + Broadcast::queue(new TestEventUniqueWithIdMethod); + + Bus::assertNotDispatched(UniqueBroadcastEvent::class); + Queue::assertPushed(UniqueBroadcastEvent::class); + + $lockKey = 'laravel_unique_job:'.TestEventUniqueWithIdMethod::class.':unique-id-method'; $this->assertFalse($this->app->get(Cache::class)->lock($lockKey, 10)->get()); } @@ -178,6 +206,16 @@ public function broadcastOn() } } +class TestEventUniqueWithIdProperty extends TestEventUnique +{ + public string $uniqueId = 'unique-id-property'; +} + +class TestEventUniqueWithIdMethod extends TestEventUnique +{ + public string $uniqueId = 'unique-id-method'; +} + class TestEventRescue implements ShouldBroadcast, ShouldRescue { /**