diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 1ea898d7ee96d..0f39f1051aac9 100644 --- a/src/Symfony/Component/Lock/CHANGELOG.md +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add support for `valkey:` / `valkeys:` schemes + * Add namespace support for `RedisStore` and `MemcacheStore` 7.2 --- diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php index ba285a5d10aee..b9c23a1bb5499 100644 --- a/src/Symfony/Component/Lock/Store/MemcachedStore.php +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -26,7 +26,9 @@ class MemcachedStore implements PersistingStoreInterface { use ExpiringStoreTrait; + private const NS_SEPARATOR = ':'; private bool $useExtendedReturn; + private string $namespace = ''; public static function isSupported(): bool { @@ -39,6 +41,7 @@ public static function isSupported(): bool public function __construct( private \Memcached $memcached, private int $initialTtl = 300, + private array $options = [], ) { if (!static::isSupported()) { throw new InvalidArgumentException('Memcached extension is required.'); @@ -47,13 +50,15 @@ public function __construct( if ($initialTtl < 1) { throw new InvalidArgumentException(\sprintf('"%s()" expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl)); } + + $this->namespace = isset($this->options['namespace']) ? $this->options['namespace'] . static::NS_SEPARATOR : ''; } public function save(Key $key): void { $token = $this->getUniqueToken($key); $key->reduceLifetime($this->initialTtl); - if (!$this->memcached->add((string) $key, $token, (int) ceil($this->initialTtl))) { + if (!$this->memcached->add($this->namespace.$key, $token, (int) ceil($this->initialTtl))) { // the lock is already acquired. It could be us. Let's try to put off. $this->putOffExpiration($key, $this->initialTtl); } @@ -64,7 +69,7 @@ public function save(Key $key): void public function putOffExpiration(Key $key, float $ttl): void { if ($ttl < 1) { - throw new InvalidTtlException(\sprintf('"%s()" expects a TTL greater or equals to 1 second. Got %s.', __METHOD__, $ttl)); + throw new InvalidTtlException(\sprintf('"%s()" expects a TTL greater or equals to 1 second. Got "%s".', __METHOD__, $ttl)); } // Interface defines a float value but Store required an integer. @@ -77,7 +82,7 @@ public function putOffExpiration(Key $key, float $ttl): void $key->reduceLifetime($ttl); // Could happens when we ask a putOff after a timeout but in luck nobody steal the lock if (\Memcached::RES_NOTFOUND === $this->memcached->getResultCode()) { - if ($this->memcached->add((string) $key, $token, $ttl)) { + if ($this->memcached->add($this->namespace.$key, $token, $ttl)) { return; } @@ -90,7 +95,7 @@ public function putOffExpiration(Key $key, float $ttl): void throw new LockConflictedException(); } - if (!$this->memcached->cas($cas, (string) $key, $token, $ttl)) { + if (!$this->memcached->cas($cas, $this->namespace.$key, $token, $ttl)) { throw new LockConflictedException(); } @@ -109,18 +114,18 @@ public function delete(Key $key): void } // To avoid concurrency in deletion, the trick is to extends the TTL then deleting the key - if (!$this->memcached->cas($cas, (string) $key, $token, 2)) { + if (!$this->memcached->cas($cas, $this->namespace.$key, $token, 2)) { // Someone steal our lock. It does not belongs to us anymore. Nothing to do. return; } // Now, we are the owner of the lock for 2 more seconds, we can delete it. - $this->memcached->delete((string) $key); + $this->memcached->delete($this->namespace.$key); } public function exists(Key $key): bool { - return $this->memcached->get((string) $key) === $this->getUniqueToken($key); + return $this->memcached->get($this->namespace.$key) === $this->getUniqueToken($key); } private function getUniqueToken(Key $key): string @@ -136,7 +141,7 @@ private function getUniqueToken(Key $key): string private function getValueAndCas(Key $key): array { if ($this->useExtendedReturn ??= version_compare(phpversion('memcached'), '2.9.9', '>')) { - $extendedReturn = $this->memcached->get((string) $key, null, \Memcached::GET_EXTENDED); + $extendedReturn = $this->memcached->get($this->namespace.$key, null, \Memcached::GET_EXTENDED); if (\Memcached::GET_ERROR_RETURN_VALUE === $extendedReturn) { return [$extendedReturn, 0.0]; } @@ -145,7 +150,7 @@ private function getValueAndCas(Key $key): array } $cas = 0.0; - $value = $this->memcached->get((string) $key, null, $cas); + $value = $this->memcached->get($this->namespace.$key, null, $cas); return [$value, $cas]; } diff --git a/src/Symfony/Component/Lock/Store/RedisStore.php b/src/Symfony/Component/Lock/Store/RedisStore.php index e23856f79a5d8..25c35ea799fad 100644 --- a/src/Symfony/Component/Lock/Store/RedisStore.php +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -32,19 +32,27 @@ class RedisStore implements SharedLockStoreInterface use ExpiringStoreTrait; private const NO_SCRIPT_ERROR_MESSAGE_PREFIX = 'NOSCRIPT'; - + private const NS_SEPARATOR = ':'; private bool $supportTime; + private string $namespace = ''; /** * @param float $initialTtl The expiration delay of locks in seconds + * @param array $options See below + * + * Options: + * namespace: Prefix used for keys */ public function __construct( private \Redis|Relay|RelayCluster|\RedisArray|\RedisCluster|\Predis\ClientInterface $redis, private float $initialTtl = 300.0, + private array $options = [], ) { if ($initialTtl <= 0) { throw new InvalidTtlException(\sprintf('"%s()" expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl)); } + + $this->namespace = isset($this->options['namespace']) ? $this->options['namespace'] . static::NS_SEPARATOR : ''; } public function save(Key $key): void @@ -85,7 +93,7 @@ public function save(Key $key): void '; $key->reduceLifetime($this->initialTtl); - if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) { + if (!$this->evaluate($script, $this->namespace.$key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) { throw new LockConflictedException(); } @@ -125,7 +133,7 @@ public function saveRead(Key $key): void '; $key->reduceLifetime($this->initialTtl); - if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) { + if (!$this->evaluate($script, $this->namespace.$key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) { throw new LockConflictedException(); } @@ -165,7 +173,7 @@ public function putOffExpiration(Key $key, float $ttl): void '; $key->reduceLifetime($ttl); - if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($ttl * 1000)])) { + if (!$this->evaluate($script, $this->namespace.$key, [microtime(true), $this->getUniqueToken($key), (int) ceil($ttl * 1000)])) { throw new LockConflictedException(); } @@ -199,7 +207,7 @@ public function delete(Key $key): void return true '; - $this->evaluate($script, (string) $key, [$this->getUniqueToken($key)]); + $this->evaluate($script, $this->namespace.$key, [$this->getUniqueToken($key)]); } public function exists(Key $key): bool @@ -225,7 +233,7 @@ public function exists(Key $key): bool return false '; - return (bool) $this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key)]); + return (bool) $this->evaluate($script, $this->namespace.$key, [microtime(true), $this->getUniqueToken($key)]); } private function evaluate(string $script, string $resource, array $args): mixed @@ -323,7 +331,7 @@ private function getNowCode(): string return 1 '; try { - $this->supportTime = 1 === $this->evaluate($script, 'symfony_check_support_time', []); + $this->supportTime = 1 === $this->evaluate($script, $this->namespace.'symfony_check_support_time', []); } catch (LockStorageException $e) { if (!str_contains($e->getMessage(), 'commands not allowed after non deterministic') && !str_contains($e->getMessage(), 'is not allowed from script script') diff --git a/src/Symfony/Component/Lock/Store/StoreFactory.php b/src/Symfony/Component/Lock/Store/StoreFactory.php index 2f99458feb889..d851ab7060444 100644 --- a/src/Symfony/Component/Lock/Store/StoreFactory.php +++ b/src/Symfony/Component/Lock/Store/StoreFactory.php @@ -69,9 +69,21 @@ public static function createStore(#[\SensitiveParameter] object|string $connect throw new InvalidArgumentException('Unsupported Redis or Memcached DSN. Try running "composer require symfony/cache".'); } $storeClass = str_starts_with($connection, 'memcached:') ? MemcachedStore::class : RedisStore::class; + + $matches = []; + $namespace = ''; + if (preg_match('/^(.*[\?&])namespace=([^&#]*)&?(([^#]*).*)$/', $connection, $matches)) { + $prefix = $matches[1]; + $namespace = $matches[2]; + if (empty($matches[4])) { + $prefix = substr($prefix, 0, -1); + } + $connection = $prefix.$matches[3]; + } + $connection = AbstractAdapter::createConnection($connection, ['lazy' => true]); - return new $storeClass($connection); + return new $storeClass($connection, options: ['namespace' => $namespace]); case str_starts_with($connection, 'mongodb'): return new MongoDbStore($connection);