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);
Copy link
Member

Choose a reason for hiding this comment

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

we might need to implement the markUnserializable (like we do for LOck) to prevent people from serializing such key.

}

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()) {
Copy link
Member

Choose a reason for hiding this comment

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

should we use === or >= ? I know the createLock method stops when the counters match, and this code is enough.
But what if by "chance" we acquire MORE lock than necessary? ie. for whatever reason, the value in getWeight is dynamic.

IMHO, it doesn't hurt readability to use if (\count($locks) >= $key->getWeight()) (here and bellow,) but it makes the code stronger.

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.');
Copy link
Member

Choose a reason for hiding this comment

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

The lock component doesn't throw in such situations, it just try to re-acquire the lock. 🤷

}

$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();
Copy link
Member

Choose a reason for hiding this comment

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

wrap in a try/catch in case one release fails?

}

$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());
Copy link
Member

Choose a reason for hiding this comment

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

why encoding the key?

$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);
Copy link
Member

Choose a reason for hiding this comment

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

if you disable the auto-release feature, you should wrap this function in a try/catch to release locks in case of an exception (ie. network error)

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
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy