Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?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\Exception;

use Symfony\Component\Lock\Exception\ExceptionInterface;

/**
* UnserializableKeyException is thrown when the key contains state that can no
* be serialized and the user try to serialize it.
* ie. Semaphore with lock states, ...
*
* @author Alexander Schranz <alexander@sulu.io>
*/
class UnserializableKeyException extends \RuntimeException implements ExceptionInterface
{
}
16 changes: 16 additions & 0 deletions src/Symfony/Component/Semaphore/Key.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\Semaphore;

use Symfony\Component\Semaphore\Exception\InvalidArgumentException;
use Symfony\Component\Semaphore\Exception\UnserializableKeyException;

/**
* Key is a container for the state of the semaphores in stores.
Expand All @@ -23,6 +24,7 @@ final class Key
{
private ?float $expiringTime = null;
private array $state = [];
private bool $serializable = true;

public function __construct(
private string $resource,
Expand Down Expand Up @@ -101,4 +103,18 @@ public function isExpired(): bool
{
return null !== $this->expiringTime && $this->expiringTime <= microtime(true);
}

public function markUnserializable(): void
{
$this->serializable = false;
}

public function __sleep(): array
{
if (!$this->serializable) {
throw new UnserializableKeyException('The key cannot be serialized.');
}

return ['resource', 'expiringTime', 'state'];
}
}
171 changes: 171 additions & 0 deletions src/Symfony/Component/Semaphore/Store/LockStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?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\Exception\LockReleasingException;
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);
$key->markUnserializable();
}

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
{
$lockReleasingException = null;
foreach ($locks as $lock) {
try {
$lock->release();
} catch (LockReleasingException $e) { // we still need to release all other locks before throw the exception
$lockReleasingException ??= $e;
}
}

if (null !== $lockReleasingException) {
throw $lockReleasingException;
}

$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 = $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);

$previousException = null;
try {
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;
}
}
} catch (\Throwable $e) {
$previousException = $e;
}

if (\count($locks) !== $key->getWeight()) {
try {
// release already acquired locks because not got the amount of locks which were required
$this->releaseLocks($locks, $key);
} catch (\Throwable $e) {
// we throw the previous exception or own SemaphoreAcquiringException here
}

$previousException ??= new SemaphoreAcquiringException($key, 'There were no free locks found');

throw $previousException;
}

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