From e674445f832a15130d6e7309cee9725d1908a3b6 Mon Sep 17 00:00:00 2001 From: Pavel Kirpitsov Date: Wed, 16 Apr 2025 15:26:36 +0300 Subject: [PATCH 1/5] [Lock] Add namespace support to Redis & Memcache lock stores --- src/Symfony/Component/Lock/CHANGELOG.md | 1 + .../Component/Lock/Store/MemcachedStore.php | 27 +++++++++++++------ .../Component/Lock/Store/RedisStore.php | 23 ++++++++++++---- .../Component/Lock/Store/StoreFactory.php | 14 +++++++++- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 1ea898d7ee96d..5c9edb95745d5 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 + * RedisStore and MemcacheStore namespace support 7.2 --- diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php index ba285a5d10aee..8c5a3ff628a51 100644 --- a/src/Symfony/Component/Lock/Store/MemcachedStore.php +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -26,8 +26,16 @@ class MemcachedStore implements PersistingStoreInterface { use ExpiringStoreTrait; + /** + * @internal + */ + private const NS_SEPARATOR = ':'; + private bool $useExtendedReturn; + private string $namespace = ''; + + public static function isSupported(): bool { return \extension_loaded('memcached'); @@ -39,6 +47,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 +56,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); } @@ -77,7 +88,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 +101,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 +120,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 +147,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 +156,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..7f6a44c93201a 100644 --- a/src/Symfony/Component/Lock/Store/RedisStore.php +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -32,19 +32,32 @@ class RedisStore implements SharedLockStoreInterface use ExpiringStoreTrait; private const NO_SCRIPT_ERROR_MESSAGE_PREFIX = 'NOSCRIPT'; + /** + * @internal + */ + 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 +98,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 +138,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 +178,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 +212,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 +238,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 diff --git a/src/Symfony/Component/Lock/Store/StoreFactory.php b/src/Symfony/Component/Lock/Store/StoreFactory.php index 2f99458feb889..34b3fa4c0cf70 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, ['namespace' => $namespace]); case str_starts_with($connection, 'mongodb'): return new MongoDbStore($connection); From 08fa69bbbd249d0dcf6ca6ade44c2f5fdf52b372 Mon Sep 17 00:00:00 2001 From: Pavel Kirpitsov Date: Wed, 16 Apr 2025 15:53:16 +0300 Subject: [PATCH 2/5] [Lock] Add namespace support to Redis & Memcache lock stores --- src/Symfony/Component/Lock/Store/StoreFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Lock/Store/StoreFactory.php b/src/Symfony/Component/Lock/Store/StoreFactory.php index 34b3fa4c0cf70..d851ab7060444 100644 --- a/src/Symfony/Component/Lock/Store/StoreFactory.php +++ b/src/Symfony/Component/Lock/Store/StoreFactory.php @@ -83,7 +83,7 @@ public static function createStore(#[\SensitiveParameter] object|string $connect $connection = AbstractAdapter::createConnection($connection, ['lazy' => true]); - return new $storeClass($connection, ['namespace' => $namespace]); + return new $storeClass($connection, options: ['namespace' => $namespace]); case str_starts_with($connection, 'mongodb'): return new MongoDbStore($connection); From c186d5e1cadbfd8f562345cb3fad40b749ee5b11 Mon Sep 17 00:00:00 2001 From: Pavel Kirpitsov Date: Wed, 16 Apr 2025 16:18:10 +0300 Subject: [PATCH 3/5] [Lock] Add namespace support to Redis & Memcache lock stores --- src/Symfony/Component/Lock/Store/MemcachedStore.php | 2 +- src/Symfony/Component/Lock/Store/RedisStore.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php index 8c5a3ff628a51..e4eaf552cb910 100644 --- a/src/Symfony/Component/Lock/Store/MemcachedStore.php +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -75,7 +75,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. diff --git a/src/Symfony/Component/Lock/Store/RedisStore.php b/src/Symfony/Component/Lock/Store/RedisStore.php index 7f6a44c93201a..19c7844307441 100644 --- a/src/Symfony/Component/Lock/Store/RedisStore.php +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -336,7 +336,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') From 788c1c1c85a8e9d20e60cfc7ce121a868d084f46 Mon Sep 17 00:00:00 2001 From: Pavel Kirpitsov Date: Tue, 22 Apr 2025 16:36:56 +0300 Subject: [PATCH 4/5] [Lock] Add namespace support to Redis & Memcache lock stores --- .../Component/Lock/Store/MemcachedStore.php | 22 +++++++------------ .../Component/Lock/Store/RedisStore.php | 17 +++++--------- 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php index e4eaf552cb910..b9c23a1bb5499 100644 --- a/src/Symfony/Component/Lock/Store/MemcachedStore.php +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -26,16 +26,10 @@ class MemcachedStore implements PersistingStoreInterface { use ExpiringStoreTrait; - /** - * @internal - */ private const NS_SEPARATOR = ':'; - private bool $useExtendedReturn; - private string $namespace = ''; - public static function isSupported(): bool { return \extension_loaded('memcached'); @@ -64,7 +58,7 @@ public function save(Key $key): void { $token = $this->getUniqueToken($key); $key->reduceLifetime($this->initialTtl); - if (!$this->memcached->add($this->namespace . $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); } @@ -88,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($this->namespace . $key, $token, $ttl)) { + if ($this->memcached->add($this->namespace.$key, $token, $ttl)) { return; } @@ -101,7 +95,7 @@ public function putOffExpiration(Key $key, float $ttl): void throw new LockConflictedException(); } - if (!$this->memcached->cas($cas, $this->namespace . $key, $token, $ttl)) { + if (!$this->memcached->cas($cas, $this->namespace.$key, $token, $ttl)) { throw new LockConflictedException(); } @@ -120,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, $this->namespace . $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($this->namespace . $key); + $this->memcached->delete($this->namespace.$key); } public function exists(Key $key): bool { - return $this->memcached->get($this->namespace . $key) === $this->getUniqueToken($key); + return $this->memcached->get($this->namespace.$key) === $this->getUniqueToken($key); } private function getUniqueToken(Key $key): string @@ -147,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($this->namespace . $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]; } @@ -156,7 +150,7 @@ private function getValueAndCas(Key $key): array } $cas = 0.0; - $value = $this->memcached->get($this->namespace . $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 19c7844307441..25c35ea799fad 100644 --- a/src/Symfony/Component/Lock/Store/RedisStore.php +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -32,13 +32,8 @@ class RedisStore implements SharedLockStoreInterface use ExpiringStoreTrait; private const NO_SCRIPT_ERROR_MESSAGE_PREFIX = 'NOSCRIPT'; - /** - * @internal - */ private const NS_SEPARATOR = ':'; - private bool $supportTime; - private string $namespace = ''; /** @@ -98,7 +93,7 @@ public function save(Key $key): void '; $key->reduceLifetime($this->initialTtl); - if (!$this->evaluate($script, $this->namespace . $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(); } @@ -138,7 +133,7 @@ public function saveRead(Key $key): void '; $key->reduceLifetime($this->initialTtl); - if (!$this->evaluate($script, $this->namespace . $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(); } @@ -178,7 +173,7 @@ public function putOffExpiration(Key $key, float $ttl): void '; $key->reduceLifetime($ttl); - if (!$this->evaluate($script, $this->namespace . $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(); } @@ -212,7 +207,7 @@ public function delete(Key $key): void return true '; - $this->evaluate($script, $this->namespace . $key, [$this->getUniqueToken($key)]); + $this->evaluate($script, $this->namespace.$key, [$this->getUniqueToken($key)]); } public function exists(Key $key): bool @@ -238,7 +233,7 @@ public function exists(Key $key): bool return false '; - return (bool) $this->evaluate($script, $this->namespace . $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 @@ -336,7 +331,7 @@ private function getNowCode(): string return 1 '; try { - $this->supportTime = 1 === $this->evaluate($script, $this->namespace . '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') From b4e39e1f91b6fe2df68723c9e862b4785b851d6b Mon Sep 17 00:00:00 2001 From: Pavel Kirpitsov Date: Mon, 19 May 2025 22:59:38 +0300 Subject: [PATCH 5/5] Update src/Symfony/Component/Lock/CHANGELOG.md Co-authored-by: Oskar Stark --- src/Symfony/Component/Lock/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 5c9edb95745d5..0f39f1051aac9 100644 --- a/src/Symfony/Component/Lock/CHANGELOG.md +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -5,7 +5,7 @@ CHANGELOG --- * Add support for `valkey:` / `valkeys:` schemes - * RedisStore and MemcacheStore namespace support + * Add namespace support for `RedisStore` and `MemcacheStore` 7.2 ---