├── CHANGELOG.md ├── Exception ├── ExceptionInterface.php ├── InvalidArgumentException.php ├── RuntimeException.php ├── SemaphoreAcquiringException.php ├── SemaphoreExpiredException.php └── SemaphoreReleasingException.php ├── Key.php ├── LICENSE ├── PersistingStoreInterface.php ├── README.md ├── Semaphore.php ├── SemaphoreFactory.php ├── SemaphoreInterface.php ├── Store ├── RedisStore.php └── StoreFactory.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Add support for `valkey:` / `valkeys:` schemes 8 | 9 | 6.3 10 | --- 11 | 12 | * Add support for Relay PHP extension for Redis 13 | 14 | 5.3 15 | --- 16 | 17 | * The component is not marked as `@experimental` anymore 18 | 19 | 5.2.0 20 | ----- 21 | 22 | * Introduced the component as experimental 23 | -------------------------------------------------------------------------------- /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Exception; 13 | 14 | /** 15 | * Base ExceptionInterface for the Semaphore Component. 16 | * 17 | * @author Jérémy Derussé 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Exception; 13 | 14 | /** 15 | * @author Jérémy Derussé 16 | */ 17 | class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Exception; 13 | 14 | /** 15 | * @author Grégoire Pineau 16 | */ 17 | class RuntimeException extends \RuntimeException implements ExceptionInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/SemaphoreAcquiringException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Exception; 13 | 14 | use Symfony\Component\Semaphore\Key; 15 | 16 | /** 17 | * SemaphoreAcquiringException is thrown when an issue happens during the acquisition of a semaphore. 18 | * 19 | * @author Jérémy Derussé 20 | * @author Grégoire Pineau 21 | */ 22 | class SemaphoreAcquiringException extends \RuntimeException implements ExceptionInterface 23 | { 24 | public function __construct(Key $key, string $message) 25 | { 26 | parent::__construct(\sprintf('The semaphore "%s" could not be acquired: %s.', $key, $message)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Exception/SemaphoreExpiredException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Exception; 13 | 14 | use Symfony\Component\Semaphore\Key; 15 | 16 | /** 17 | * SemaphoreExpiredException is thrown when a semaphore may conflict due to a TTL expiration. 18 | * 19 | * @author Jérémy Derussé 20 | * @author Grégoire Pineau 21 | */ 22 | class SemaphoreExpiredException extends \RuntimeException implements ExceptionInterface 23 | { 24 | public function __construct(Key $key, string $message) 25 | { 26 | parent::__construct(\sprintf('The semaphore "%s" has expired: %s.', $key, $message)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Exception/SemaphoreReleasingException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Exception; 13 | 14 | use Symfony\Component\Semaphore\Key; 15 | 16 | /** 17 | * SemaphoreReleasingException is thrown when an issue happens during the release of a semaphore. 18 | * 19 | * @author Jérémy Derussé 20 | * @author Grégoire Pineau 21 | */ 22 | class SemaphoreReleasingException extends \RuntimeException implements ExceptionInterface 23 | { 24 | public function __construct(Key $key, string $message) 25 | { 26 | parent::__construct(\sprintf('The semaphore "%s" could not be released: %s.', $key, $message)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Key.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore; 13 | 14 | use Symfony\Component\Semaphore\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * Key is a container for the state of the semaphores in stores. 18 | * 19 | * @author Grégoire Pineau 20 | * @author Jérémy Derussé 21 | */ 22 | final class Key 23 | { 24 | private ?float $expiringTime = null; 25 | private array $state = []; 26 | 27 | public function __construct( 28 | private string $resource, 29 | private int $limit, 30 | private int $weight = 1, 31 | ) { 32 | if (1 > $limit) { 33 | throw new InvalidArgumentException("The limit ($limit) should be greater than 0."); 34 | } 35 | if (1 > $weight) { 36 | throw new InvalidArgumentException("The weight ($weight) should be greater than 0."); 37 | } 38 | if ($weight > $limit) { 39 | throw new InvalidArgumentException("The weight ($weight) should be lower or equals to the limit ($limit)."); 40 | } 41 | } 42 | 43 | public function __toString(): string 44 | { 45 | return $this->resource; 46 | } 47 | 48 | public function getLimit(): int 49 | { 50 | return $this->limit; 51 | } 52 | 53 | public function getWeight(): int 54 | { 55 | return $this->weight; 56 | } 57 | 58 | public function hasState(string $stateKey): bool 59 | { 60 | return isset($this->state[$stateKey]); 61 | } 62 | 63 | public function setState(string $stateKey, mixed $state): void 64 | { 65 | $this->state[$stateKey] = $state; 66 | } 67 | 68 | public function removeState(string $stateKey): void 69 | { 70 | unset($this->state[$stateKey]); 71 | } 72 | 73 | public function getState(string $stateKey): mixed 74 | { 75 | return $this->state[$stateKey]; 76 | } 77 | 78 | public function resetLifetime(): void 79 | { 80 | $this->expiringTime = null; 81 | } 82 | 83 | public function reduceLifetime(float $ttlInSeconds): void 84 | { 85 | $newTime = microtime(true) + $ttlInSeconds; 86 | 87 | if (null === $this->expiringTime || $this->expiringTime > $newTime) { 88 | $this->expiringTime = $newTime; 89 | } 90 | } 91 | 92 | /** 93 | * @return float|null Remaining lifetime in seconds. Null when the key won't expire. 94 | */ 95 | public function getRemainingLifetime(): ?float 96 | { 97 | return null === $this->expiringTime ? null : $this->expiringTime - microtime(true); 98 | } 99 | 100 | public function isExpired(): bool 101 | { 102 | return null !== $this->expiringTime && $this->expiringTime <= microtime(true); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /PersistingStoreInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore; 13 | 14 | use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; 15 | use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; 16 | use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; 17 | 18 | /** 19 | * @author Grégoire Pineau 20 | * @author Jérémy Derussé 21 | */ 22 | interface PersistingStoreInterface 23 | { 24 | /** 25 | * Stores the resource if the semaphore is not full. 26 | * 27 | * @throws SemaphoreAcquiringException 28 | */ 29 | public function save(Key $key, float $ttlInSecond): void; 30 | 31 | /** 32 | * Removes a resource from the storage. 33 | * 34 | * @throws SemaphoreReleasingException 35 | */ 36 | public function delete(Key $key): void; 37 | 38 | /** 39 | * Returns whether or not the resource exists in the storage. 40 | */ 41 | public function exists(Key $key): bool; 42 | 43 | /** 44 | * Extends the TTL of a resource. 45 | * 46 | * @throws SemaphoreExpiredException 47 | */ 48 | public function putOffExpiration(Key $key, float $ttlInSecond): void; 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Semaphore Component 2 | =================== 3 | 4 | The Semaphore Component manages 5 | [semaphores](https://en.wikipedia.org/wiki/Semaphore_(programming)), a mechanism 6 | to provide exclusive access to a shared resource. 7 | 8 | Resources 9 | --------- 10 | 11 | * [Documentation](https://symfony.com/doc/current/components/semaphore.html) 12 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 13 | * [Report issues](https://github.com/symfony/symfony/issues) and 14 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 15 | in the [main Symfony repository](https://github.com/symfony/symfony) 16 | -------------------------------------------------------------------------------- /Semaphore.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore; 13 | 14 | use Psr\Log\LoggerAwareInterface; 15 | use Psr\Log\LoggerAwareTrait; 16 | use Symfony\Component\Semaphore\Exception\InvalidArgumentException; 17 | use Symfony\Component\Semaphore\Exception\RuntimeException; 18 | use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; 19 | use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; 20 | use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; 21 | 22 | /** 23 | * Semaphore is the default implementation of the SemaphoreInterface. 24 | * 25 | * @author Grégoire Pineau 26 | * @author Jérémy Derussé 27 | */ 28 | final class Semaphore implements SemaphoreInterface, LoggerAwareInterface 29 | { 30 | use LoggerAwareTrait; 31 | 32 | private bool $dirty = false; 33 | 34 | public function __construct( 35 | private Key $key, 36 | private PersistingStoreInterface $store, 37 | private float $ttlInSecond = 300.0, 38 | private bool $autoRelease = true, 39 | ) { 40 | } 41 | 42 | public function __sleep(): array 43 | { 44 | throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 45 | } 46 | 47 | public function __wakeup(): void 48 | { 49 | throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 50 | } 51 | 52 | /** 53 | * Automatically releases the underlying semaphore when the object is destructed. 54 | */ 55 | public function __destruct() 56 | { 57 | if (!$this->autoRelease || !$this->dirty || !$this->isAcquired()) { 58 | return; 59 | } 60 | 61 | $this->release(); 62 | } 63 | 64 | public function acquire(): bool 65 | { 66 | try { 67 | $this->key->resetLifetime(); 68 | $this->store->save($this->key, $this->ttlInSecond); 69 | $this->key->reduceLifetime($this->ttlInSecond); 70 | $this->dirty = true; 71 | 72 | $this->logger?->debug('Successfully acquired the "{resource}" semaphore.', ['resource' => $this->key]); 73 | 74 | return true; 75 | } catch (SemaphoreAcquiringException) { 76 | $this->logger?->notice('Failed to acquire the "{resource}" semaphore. Someone else already acquired the semaphore.', ['resource' => $this->key]); 77 | 78 | return false; 79 | } catch (\Exception $e) { 80 | $this->logger?->notice('Failed to acquire the "{resource}" semaphore.', ['resource' => $this->key, 'exception' => $e]); 81 | 82 | throw new RuntimeException(\sprintf('Failed to acquire the "%s" semaphore.', $this->key), 0, $e); 83 | } 84 | } 85 | 86 | public function refresh(?float $ttlInSecond = null): void 87 | { 88 | if (!$ttlInSecond ??= $this->ttlInSecond) { 89 | throw new InvalidArgumentException('You have to define an expiration duration.'); 90 | } 91 | 92 | try { 93 | $this->key->resetLifetime(); 94 | $this->store->putOffExpiration($this->key, $ttlInSecond); 95 | $this->key->reduceLifetime($ttlInSecond); 96 | 97 | $this->dirty = true; 98 | 99 | $this->logger?->debug('Expiration defined for "{resource}" semaphore for "{ttlInSecond}" seconds.', ['resource' => $this->key, 'ttlInSecond' => $ttlInSecond]); 100 | } catch (SemaphoreExpiredException $e) { 101 | $this->dirty = false; 102 | $this->logger?->notice('Failed to define an expiration for the "{resource}" semaphore, the semaphore has expired.', ['resource' => $this->key]); 103 | 104 | throw $e; 105 | } catch (\Exception $e) { 106 | $this->logger?->notice('Failed to define an expiration for the "{resource}" semaphore.', ['resource' => $this->key, 'exception' => $e]); 107 | 108 | throw new RuntimeException(\sprintf('Failed to define an expiration for the "%s" semaphore.', $this->key), 0, $e); 109 | } 110 | } 111 | 112 | public function isAcquired(): bool 113 | { 114 | return $this->dirty = $this->store->exists($this->key); 115 | } 116 | 117 | public function release(): void 118 | { 119 | try { 120 | $this->store->delete($this->key); 121 | $this->dirty = false; 122 | } catch (SemaphoreReleasingException $e) { 123 | throw $e; 124 | } catch (\Exception $e) { 125 | $this->logger?->notice('Failed to release the "{resource}" semaphore.', ['resource' => $this->key]); 126 | 127 | throw new RuntimeException(\sprintf('Failed to release the "%s" semaphore.', $this->key), 0, $e); 128 | } 129 | } 130 | 131 | public function isExpired(): bool 132 | { 133 | return $this->key->isExpired(); 134 | } 135 | 136 | public function getRemainingLifetime(): ?float 137 | { 138 | return $this->key->getRemainingLifetime(); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /SemaphoreFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore; 13 | 14 | use Psr\Log\LoggerAwareInterface; 15 | use Psr\Log\LoggerAwareTrait; 16 | 17 | /** 18 | * Factory provides method to create semaphores. 19 | * 20 | * @author Grégoire Pineau 21 | * @author Jérémy Derussé 22 | * @author Hamza Amrouche 23 | */ 24 | class SemaphoreFactory implements LoggerAwareInterface 25 | { 26 | use LoggerAwareTrait; 27 | 28 | public function __construct( 29 | private PersistingStoreInterface $store, 30 | ) { 31 | } 32 | 33 | /** 34 | * @param float|null $ttlInSecond Maximum expected semaphore duration in seconds 35 | * @param bool $autoRelease Whether to automatically release the semaphore or not when the semaphore instance is destroyed 36 | */ 37 | public function createSemaphore(string $resource, int $limit, int $weight = 1, ?float $ttlInSecond = 300.0, bool $autoRelease = true): SemaphoreInterface 38 | { 39 | return $this->createSemaphoreFromKey(new Key($resource, $limit, $weight), $ttlInSecond, $autoRelease); 40 | } 41 | 42 | /** 43 | * @param float|null $ttlInSecond Maximum expected semaphore duration in seconds 44 | * @param bool $autoRelease Whether to automatically release the semaphore or not when the semaphore instance is destroyed 45 | */ 46 | public function createSemaphoreFromKey(Key $key, ?float $ttlInSecond = 300.0, bool $autoRelease = true): SemaphoreInterface 47 | { 48 | $semaphore = new Semaphore($key, $this->store, $ttlInSecond, $autoRelease); 49 | if ($this->logger) { 50 | $semaphore->setLogger($this->logger); 51 | } 52 | 53 | return $semaphore; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /SemaphoreInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore; 13 | 14 | use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; 15 | use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; 16 | use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; 17 | 18 | /** 19 | * SemaphoreInterface defines an interface to manipulate the status of a semaphore. 20 | * 21 | * @author Jérémy Derussé 22 | * @author Grégoire Pineau 23 | */ 24 | interface SemaphoreInterface 25 | { 26 | /** 27 | * Acquires the semaphore. If the semaphore has reached its limit. 28 | * 29 | * @throws SemaphoreAcquiringException If the semaphore cannot be acquired 30 | */ 31 | public function acquire(): bool; 32 | 33 | /** 34 | * Increase the duration of an acquired semaphore. 35 | * 36 | * @throws SemaphoreExpiredException If the semaphore has expired 37 | */ 38 | public function refresh(?float $ttlInSecond = null): void; 39 | 40 | /** 41 | * Returns whether or not the semaphore is acquired. 42 | */ 43 | public function isAcquired(): bool; 44 | 45 | /** 46 | * Release the semaphore. 47 | * 48 | * @throws SemaphoreReleasingException If the semaphore cannot be released 49 | */ 50 | public function release(): void; 51 | 52 | public function isExpired(): bool; 53 | 54 | /** 55 | * Returns the remaining lifetime. 56 | */ 57 | public function getRemainingLifetime(): ?float; 58 | } 59 | -------------------------------------------------------------------------------- /Store/RedisStore.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Store; 13 | 14 | use Relay\Cluster as RelayCluster; 15 | use Relay\Relay; 16 | use Symfony\Component\Semaphore\Exception\InvalidArgumentException; 17 | use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; 18 | use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; 19 | use Symfony\Component\Semaphore\Key; 20 | use Symfony\Component\Semaphore\PersistingStoreInterface; 21 | 22 | /** 23 | * RedisStore is a PersistingStoreInterface implementation using Redis as store engine. 24 | * 25 | * @author Grégoire Pineau 26 | * @author Jérémy Derussé 27 | */ 28 | class RedisStore implements PersistingStoreInterface 29 | { 30 | public function __construct( 31 | private \Redis|Relay|RelayCluster|\RedisArray|\RedisCluster|\Predis\ClientInterface $redis, 32 | ) { 33 | } 34 | 35 | public function save(Key $key, float $ttlInSecond): void 36 | { 37 | if (0 > $ttlInSecond) { 38 | throw new InvalidArgumentException("The TTL should be greater than 0, '$ttlInSecond' given."); 39 | } 40 | 41 | $script = ' 42 | local key = KEYS[1] 43 | local weightKey = key .. ":weight" 44 | local timeKey = key .. ":time" 45 | local identifier = ARGV[1] 46 | local now = tonumber(ARGV[2]) 47 | local ttlInSecond = tonumber(ARGV[3]) 48 | local limit = tonumber(ARGV[4]) 49 | local weight = tonumber(ARGV[5]) 50 | 51 | -- Remove expired values 52 | redis.call("ZREMRANGEBYSCORE", timeKey, "-inf", now) 53 | redis.call("ZINTERSTORE", weightKey, 2, weightKey, timeKey, "WEIGHTS", 1, 0) 54 | 55 | -- Semaphore already acquired? 56 | if redis.call("ZSCORE", timeKey, identifier) then 57 | return true 58 | end 59 | 60 | -- Try to get a semaphore 61 | local semaphores = redis.call("ZRANGE", weightKey, 0, -1, "WITHSCORES") 62 | local count = 0 63 | 64 | for i = 1, #semaphores, 2 do 65 | count = count + semaphores[i+1] 66 | end 67 | 68 | -- Could we get the semaphore ? 69 | if count + weight > limit then 70 | return false 71 | end 72 | 73 | -- Acquire the semaphore 74 | redis.call("ZADD", timeKey, now + ttlInSecond, identifier) 75 | redis.call("ZADD", weightKey, weight, identifier) 76 | 77 | -- Extend the TTL 78 | local maxExpiration = redis.call("ZREVRANGE", timeKey, 0, 0, "WITHSCORES")[2] 79 | redis.call("EXPIREAT", weightKey, maxExpiration + 10) 80 | redis.call("EXPIREAT", timeKey, maxExpiration + 10) 81 | 82 | return true 83 | '; 84 | 85 | $args = [ 86 | $this->getUniqueToken($key), 87 | time(), 88 | $ttlInSecond, 89 | $key->getLimit(), 90 | $key->getWeight(), 91 | ]; 92 | 93 | if (!$this->evaluate($script, \sprintf('{%s}', $key), $args)) { 94 | throw new SemaphoreAcquiringException($key, 'the script return false'); 95 | } 96 | } 97 | 98 | public function putOffExpiration(Key $key, float $ttlInSecond): void 99 | { 100 | if (0 > $ttlInSecond) { 101 | throw new InvalidArgumentException("The TTL should be greater than 0, '$ttlInSecond' given."); 102 | } 103 | 104 | $script = ' 105 | local key = KEYS[1] 106 | local weightKey = key .. ":weight" 107 | local timeKey = key .. ":time" 108 | 109 | local added = redis.call("ZADD", timeKey, ARGV[1], ARGV[2]) 110 | if added == 1 then 111 | redis.call("ZREM", timeKey, ARGV[2]) 112 | redis.call("ZREM", weightKey, ARGV[2]) 113 | end 114 | 115 | -- Extend the TTL 116 | local maxExpiration = redis.call("ZREVRANGE", timeKey, 0, 0, "WITHSCORES")[2] 117 | if nil == maxExpiration then 118 | return 1 119 | end 120 | 121 | redis.call("EXPIREAT", weightKey, maxExpiration + 10) 122 | redis.call("EXPIREAT", timeKey, maxExpiration + 10) 123 | 124 | return added 125 | '; 126 | 127 | $ret = $this->evaluate($script, \sprintf('{%s}', $key), [time() + $ttlInSecond, $this->getUniqueToken($key)]); 128 | 129 | // Occurs when redis has been reset 130 | if (false === $ret) { 131 | throw new SemaphoreExpiredException($key, 'the script returns false'); 132 | } 133 | 134 | // Occurs when redis has added an item in the set 135 | if (0 < $ret) { 136 | throw new SemaphoreExpiredException($key, 'the script returns a positive number'); 137 | } 138 | } 139 | 140 | public function delete(Key $key): void 141 | { 142 | $script = ' 143 | local key = KEYS[1] 144 | local weightKey = key .. ":weight" 145 | local timeKey = key .. ":time" 146 | local identifier = ARGV[1] 147 | 148 | redis.call("ZREM", timeKey, identifier) 149 | return redis.call("ZREM", weightKey, identifier) 150 | '; 151 | 152 | $this->evaluate($script, \sprintf('{%s}', $key), [$this->getUniqueToken($key)]); 153 | } 154 | 155 | public function exists(Key $key): bool 156 | { 157 | return (bool) $this->redis->zScore(\sprintf('{%s}:weight', $key), $this->getUniqueToken($key)); 158 | } 159 | 160 | private function evaluate(string $script, string $resource, array $args): mixed 161 | { 162 | if ($this->redis instanceof \Redis || $this->redis instanceof Relay || $this->redis instanceof RelayCluster || $this->redis instanceof \RedisCluster) { 163 | return $this->redis->eval($script, array_merge([$resource], $args), 1); 164 | } 165 | 166 | if ($this->redis instanceof \RedisArray) { 167 | return $this->redis->_instance($this->redis->_target($resource))->eval($script, array_merge([$resource], $args), 1); 168 | } 169 | 170 | if ($this->redis instanceof \Predis\ClientInterface) { 171 | return $this->redis->eval(...array_merge([$script, 1, $resource], $args)); 172 | } 173 | 174 | throw new InvalidArgumentException(\sprintf('"%s()" expects being initialized with a Redis, RedisArray, RedisCluster or Predis\ClientInterface, "%s" given.', __METHOD__, get_debug_type($this->redis))); 175 | } 176 | 177 | private function getUniqueToken(Key $key): string 178 | { 179 | if (!$key->hasState(__CLASS__)) { 180 | $token = base64_encode(random_bytes(32)); 181 | $key->setState(__CLASS__, $token); 182 | } 183 | 184 | return $key->getState(__CLASS__); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Store/StoreFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Semaphore\Store; 13 | 14 | use Relay\Relay; 15 | use Symfony\Component\Cache\Adapter\AbstractAdapter; 16 | use Symfony\Component\Semaphore\Exception\InvalidArgumentException; 17 | use Symfony\Component\Semaphore\PersistingStoreInterface; 18 | 19 | /** 20 | * StoreFactory create stores and connections. 21 | * 22 | * @author Jérémy Derussé 23 | */ 24 | class StoreFactory 25 | { 26 | public static function createStore(#[\SensitiveParameter] object|string $connection): PersistingStoreInterface 27 | { 28 | switch (true) { 29 | case $connection instanceof \Redis: 30 | case $connection instanceof Relay: 31 | case $connection instanceof \RedisArray: 32 | case $connection instanceof \RedisCluster: 33 | case $connection instanceof \Predis\ClientInterface: 34 | return new RedisStore($connection); 35 | 36 | case !\is_string($connection): 37 | throw new InvalidArgumentException(\sprintf('Unsupported Connection: "%s".', $connection::class)); 38 | case str_starts_with($connection, 'redis:'): 39 | case str_starts_with($connection, 'rediss:'): 40 | case str_starts_with($connection, 'valkey:'): 41 | case str_starts_with($connection, 'valkeys:'): 42 | if (!class_exists(AbstractAdapter::class)) { 43 | throw new InvalidArgumentException('Unsupported Redis DSN. Try running "composer require symfony/cache".'); 44 | } 45 | $connection = AbstractAdapter::createConnection($connection, ['lazy' => true]); 46 | 47 | return new RedisStore($connection); 48 | } 49 | 50 | throw new InvalidArgumentException(\sprintf('Unsupported Connection: "%s".', $connection)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/semaphore", 3 | "type": "library", 4 | "description": "Symfony Semaphore Component", 5 | "keywords": ["semaphore"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Grégoire Pineau", 11 | "email": "lyrixx@lyrixx.info" 12 | }, 13 | { 14 | "name": "Jérémy Derussé", 15 | "email": "jeremy@derusse.com" 16 | }, 17 | { 18 | "name": "Symfony Community", 19 | "homepage": "https://symfony.com/contributors" 20 | } 21 | ], 22 | "require": { 23 | "php": ">=8.2", 24 | "psr/log": "^1|^2|^3" 25 | }, 26 | "require-dev": { 27 | "predis/predis": "^1.1|^2.0" 28 | }, 29 | "conflict": { 30 | "symfony/cache": "<6.4" 31 | }, 32 | "autoload": { 33 | "psr-4": { "Symfony\\Component\\Semaphore\\": "" }, 34 | "exclude-from-classmap": [ 35 | "/Tests/" 36 | ] 37 | }, 38 | "minimum-stability": "dev" 39 | } 40 | --------------------------------------------------------------------------------