diff --git a/src/Symfony/Component/Semaphore/Store/LockStore.php b/src/Symfony/Component/Semaphore/Store/LockStore.php new file mode 100644 index 0000000000000..5cc77ff7ca1b2 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Store/LockStore.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Store; + +use Symfony\Component\Lock\LockFactory; +use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; +use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; +use Symfony\Component\Semaphore\Key; +use Symfony\Component\Semaphore\PersistingStoreInterface; + +/** + * @author Alexander Schranz + */ +final class LockStore implements PersistingStoreInterface +{ + public function __construct( + private readonly LockFactory $lockFactory, + ) { + } + + public function save(Key $key, float $ttlInSecond): void + { + $locks = $this->getExistingLocks($key); + + if ([] !== $locks) { + return; + } + + $locks = $this->createLocks($key, $ttlInSecond); + + $key->setState(__CLASS__, $locks); + } + + public function delete(Key $key): void + { + $this->releaseLocks($this->getExistingLocks($key), $key); + } + + public function exists(Key $key): bool + { + $locks = $this->getExistingLocks($key); + + if (\count($locks) === $key->getWeight()) { + return true; + } + + $this->releaseLocks($locks, $key); + + return false; + } + + public function putOffExpiration(Key $key, float $ttlInSecond): void + { + $locks = $this->getExistingLocks($key); + foreach ($locks as $lock) { + if ($lock->isExpired()) { + $this->releaseLocks($locks, $key); + + throw new SemaphoreExpiredException($key, 'One or multiple locks are already expired.'); + } + + $lock->refresh($ttlInSecond); + } + + if (\count($locks) !== $key->getWeight()) { + $this->releaseLocks($locks, $key); + + throw new SemaphoreExpiredException($key, 'One or multiple locks were not even acquired.'); + } + } + + /** + * @param array $locks + */ + private function releaseLocks(array $locks, Key $key): void + { + foreach ($locks as $lock) { + $lock->release(); + } + + $key->setState(__CLASS__, null); + } + + /** + * @return array + */ + private function getExistingLocks(Key $key): array + { + if ($key->hasState(__CLASS__)) { + return $key->getState(__CLASS__); + } + + return []; + } + + /** + * @return array + */ + private function createLocks(Key $key, float $ttlInSecond): array + { + $locks = []; + $lockName = base64_encode($key->__toString()); + $limit = $key->getLimit(); + + // use a random start point to have a higher chance to catch a free slot directly + $startPoint = rand(0, $limit - 1); + + for ($i = 0; $i < $limit; ++$i) { + $index = ($startPoint + $i) % $limit; + + $lock = $this->lockFactory->createLock($lockName.'_'.$index, $ttlInSecond, false); + if ($lock->acquire(false)) { // use lock if we can acquire it else try to catch next lock + $locks[] = $lock; + + if (\count($locks) === $key->getWeight()) { + break; + } + + continue; + } + + $acquired = \count($locks); + $required = $key->getWeight(); + $remaining = $key->getLimit() - $i; + if (($acquired + $remaining) < $required) { // no chance to get enough locks + break; + } + } + + if (\count($locks) !== $key->getWeight()) { + // release already acquired locks because not got the amount of locks which were required + $this->releaseLocks($locks, $key); + + throw new SemaphoreAcquiringException($key, 'There were no free locks found'); + } + + return $locks; + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTestCase.php b/src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTestCase.php index 4cd89458f5d92..aafd59adf93bd 100644 --- a/src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTestCase.php +++ b/src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTestCase.php @@ -204,7 +204,6 @@ public function testPutOffExpirationWhenSaveHasNotBeenCalled() $key1 = new Key(__METHOD__, 4, 2); $this->expectException(SemaphoreExpiredException::class); - $this->expectExceptionMessage('The semaphore "Symfony\Component\Semaphore\Tests\Store\AbstractStoreTestCase::testPutOffExpirationWhenSaveHasNotBeenCalled" has expired: the script returns a positive number.'); $store->putOffExpiration($key1, 20); } diff --git a/src/Symfony/Component/Semaphore/Tests/Store/LockStoreTest.php b/src/Symfony/Component/Semaphore/Tests/Store/LockStoreTest.php new file mode 100644 index 0000000000000..f84ecfafd6870 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/LockStoreTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests\Store; + +use Symfony\Component\Lock\LockFactory; +use Symfony\Component\Lock\Store\FlockStore; +use Symfony\Component\Semaphore\PersistingStoreInterface; +use Symfony\Component\Semaphore\Store\LockStore; + +/** + * @author Alexander Schranz + */ +class LockStoreTest extends AbstractStoreTestCase +{ + public function getStore(): PersistingStoreInterface + { + $lock = new FlockStore(); + $factory = new LockFactory($lock); + + return new LockStore($factory); + } +} diff --git a/src/Symfony/Component/Semaphore/composer.json b/src/Symfony/Component/Semaphore/composer.json index a620c60cca25a..6c736a6e466c3 100644 --- a/src/Symfony/Component/Semaphore/composer.json +++ b/src/Symfony/Component/Semaphore/composer.json @@ -24,7 +24,8 @@ "psr/log": "^1|^2|^3" }, "require-dev": { - "predis/predis": "^1.1|^2.0" + "predis/predis": "^1.1|^2.0", + "symfony/lock": "^5.4 || ^6.0 || ^7.0" }, "conflict": { "symfony/cache": "<6.4"