diff --git a/src/Illuminate/Cache/Repository.php b/src/Illuminate/Cache/Repository.php index 4e265b9f3bfb..598ff92f6263 100755 --- a/src/Illuminate/Cache/Repository.php +++ b/src/Illuminate/Cache/Repository.php @@ -538,6 +538,25 @@ public function flexible($key, $ttl, $callback, $lock = null, $alwaysDefer = fal return $value; } + /** + * Execute a callback while holding an atomic lock on a cache mutex to prevent overlapping calls. + * + * @template TReturn + * + * @param string $key + * @param callable(): TReturn $callback + * @param int $lockSeconds + * @param int $waitSeconds + * @param string|null $owner + * @return TReturn + * + * @throws \Illuminate\Contracts\Cache\LockTimeoutException + */ + public function withoutOverlapping($key, callable $callback, $lockSeconds = 600, $waitSeconds = 10, $owner = null) + { + return $this->store->lock($key, $lockSeconds, $owner)->block($waitSeconds, $callback); + } + /** * Remove an item from the cache. * diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 5097a2797795..1afc63e720e9 100755 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -9,11 +9,14 @@ use DateTimeImmutable; use Illuminate\Cache\ArrayStore; use Illuminate\Cache\FileStore; +use Illuminate\Cache\Lock; use Illuminate\Cache\RedisStore; use Illuminate\Cache\Repository; use Illuminate\Cache\TaggableStore; use Illuminate\Cache\TaggedCache; use Illuminate\Container\Container; +use Illuminate\Contracts\Cache\LockProvider; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Contracts\Cache\Store; use Illuminate\Events\Dispatcher; use Illuminate\Filesystem\Filesystem; @@ -433,6 +436,72 @@ public function testNonTaggableRepositoryDoesNotSupportTags() $this->assertFalse($nonTaggableRepo->supportsTags()); } + public function testAtomicExecutesCallbackAndReturnsResult() + { + $repo = new Repository(new ArrayStore); + + $result = $repo->withoutOverlapping('foo', function () { + return 'bar'; + }); + + $this->assertSame('bar', $result); + } + + public function testAtomicPassesLockAndWaitSecondsToLock() + { + $store = m::mock(Store::class, LockProvider::class); + $repo = new Repository($store); + $lock = m::mock(Lock::class); + + $store->shouldReceive('lock')->once()->with('foo', 30, null)->andReturn($lock); + $lock->shouldReceive('block')->once()->with(15, m::type('callable'))->andReturnUsing(function ($seconds, $callback) { + return $callback(); + }); + + $result = $repo->withoutOverlapping('foo', function () { + return 'bar'; + }, 30, 15); + + $this->assertSame('bar', $result); + } + + public function testAtomicPassesOwnerToLock() + { + $store = m::mock(Store::class, LockProvider::class); + $repo = new Repository($store); + $lock = m::mock(Lock::class); + + $store->shouldReceive('lock')->once()->with('foo', 10, 'my-owner')->andReturn($lock); + $lock->shouldReceive('block')->once()->with(10, m::type('callable'))->andReturnUsing(function ($seconds, $callback) { + return $callback(); + }); + + $result = $repo->withoutOverlapping('foo', function () { + return 'bar'; + }, 10, 10, 'my-owner'); + + $this->assertSame('bar', $result); + } + + public function testAtomicThrowsOnLockTimeout() + { + $repo = new Repository(new ArrayStore); + + $repo->getStore()->lock('foo', 10)->acquire(); + + $called = false; + + try { + $repo->withoutOverlapping('foo', function () use (&$called) { + $called = true; + }, 10, 0); + + $this->fail('Expected LockTimeoutException was not thrown.'); + } catch (LockTimeoutException) { + $this->assertFalse($called); + } + } + protected function getRepository() { $dispatcher = new Dispatcher(m::mock(Container::class));