Skip to content

[Semaphore] Add a semaphore store based on locks #59202

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/Symfony/Component/Semaphore/Store/LockStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <alexander@sulu.io>
*/
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<LockInterface> $locks
*/
private function releaseLocks(array $locks, Key $key): void
{
foreach ($locks as $lock) {
$lock->release();
}

$key->setState(__CLASS__, null);
}

/**
* @return array<LockInterface>
*/
private function getExistingLocks(Key $key): array
{
if ($key->hasState(__CLASS__)) {
return $key->getState(__CLASS__);
}

return [];
}

/**
* @return array<LockInterface>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
31 changes: 31 additions & 0 deletions src/Symfony/Component/Semaphore/Tests/Store/LockStoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <alexander@sulu.io>
*/
class LockStoreTest extends AbstractStoreTestCase
{
public function getStore(): PersistingStoreInterface
{
$lock = new FlockStore();
$factory = new LockFactory($lock);

return new LockStore($factory);
}
}
3 changes: 2 additions & 1 deletion src/Symfony/Component/Semaphore/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really want to allow those old versions here?

Copy link
Contributor Author

@alexander-schranz alexander-schranz May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can change to everything the core team wants. Currently it works on this versions and I started with the 5.4 as its still supported LTS until 2029.

},
"conflict": {
"symfony/cache": "<6.4"
Expand Down
Loading