* @author Grégoire Pineau */ class RedisStore implements SharedLockStoreInterface { use ExpiringStoreTrait; private \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface|RedisProxy|RedisClusterProxy $redis; private float $initialTtl; private bool $supportTime; /** * @param float $initialTtl The expiration delay of locks in seconds */ public function __construct( \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface|RedisProxy|RedisClusterProxy $redis, float $initialTtl = 300.0, ) { if ($initialTtl <= 0) { throw new InvalidTtlException(sprintf('"%s()" expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl)); } $this->redis = $redis; $this->initialTtl = $initialTtl; } /** * {@inheritdoc} */ public function save(Key $key) { $script = "\n local key = KEYS[1]\n local uniqueToken = ARGV[2]\n local ttl = tonumber(ARGV[3])\n\n -- asserts the KEY is compatible with current version (old Symfony <5.2 BC)\n if redis.call("TYPE", key).ok == "string" then\n return false\n end\n\n ".$this->getNowCode()."\n\n -- Remove expired values\n redis.call("ZREMRANGEBYSCORE", key, "-inf", now)\n\n -- is already acquired\n if redis.call("ZSCORE", key, uniqueToken) then\n -- is not WRITE lock and cannot be promoted\n if not redis.call("ZSCORE", key, "__write__") and redis.call("ZCOUNT", key, "-inf", "+inf") > 1 then\n return false\n end\n elseif redis.call("ZCOUNT", key, "-inf", "+inf") > 0 then\n return false\n end\n\n redis.call("ZADD", key, now + ttl, uniqueToken)\n redis.call("ZADD", key, now + ttl, "__write__")\n\n -- Extend the TTL of the key\n local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]\n redis.call("PEXPIREAT", key, maxExpiration)\n\n return true\n "; $key->reduceLifetime($this->initialTtl); if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) { throw new LockConflictedException(); } $this->checkNotExpired($key); } /** * {@inheritdoc} */ public function saveRead(Key $key) { $script = "\n local key = KEYS[1]\n local uniqueToken = ARGV[2]\n local ttl = tonumber(ARGV[3])\n\n -- asserts the KEY is compatible with current version (old Symfony <5.2 BC)\n if redis.call("TYPE", key).ok == "string" then\n return false\n end\n\n ".$this->getNowCode()."\n\n -- Remove expired values\n redis.call("ZREMRANGEBYSCORE", key, "-inf", now)\n\n -- lock not already acquired and a WRITE lock exists?\n if not redis.call("ZSCORE", key, uniqueToken) and redis.call("ZSCORE", key, "__write__") then\n return false\n end\n\n redis.call("ZADD", key, now + ttl, uniqueToken)\n redis.call("ZREM", key, "__write__")\n\n -- Extend the TTL of the key\n local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]\n redis.call("PEXPIREAT", key, maxExpiration)\n\n return true\n "; $key->reduceLifetime($this->initialTtl); if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) { throw new LockConflictedException(); } $this->checkNotExpired($key); } /** * {@inheritdoc} */ public function putOffExpiration(Key $key, float $ttl) { $script = "\n local key = KEYS[1]\n local uniqueToken = ARGV[2]\n local ttl = tonumber(ARGV[3])\n\n -- asserts the KEY is compatible with current version (old Symfony <5.2 BC)\n if redis.call("TYPE", key).ok == "string" then\n return false\n end\n\n ".$this->getNowCode()."\n\n -- lock already acquired acquired?\n if not redis.call("ZSCORE", key, uniqueToken) then\n return false\n end\n\n redis.call("ZADD", key, now + ttl, uniqueToken)\n -- if the lock is also a WRITE lock, increase the TTL\n if redis.call("ZSCORE", key, "__write__") then\n redis.call("ZADD", key, now + ttl, "__write__")\n end\n\n -- Extend the TTL of the key\n local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]\n redis.call("PEXPIREAT", key, maxExpiration)\n\n return true\n "; $key->reduceLifetime($ttl); if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($ttl * 1000)])) { throw new LockConflictedException(); } $this->checkNotExpired($key); } /** * {@inheritdoc} */ public function delete(Key $key) { $script = "\n local key = KEYS[1]\n local uniqueToken = ARGV[1]\n\n -- asserts the KEY is compatible with current version (old Symfony <5.2 BC)\n if redis.call("TYPE", key).ok == "string" then\n return false\n end\n\n -- lock not already acquired\n if not redis.call("ZSCORE", key, uniqueToken) then\n return false\n end\n\n redis.call("ZREM", key, uniqueToken)\n redis.call("ZREM", key, "__write__")\n\n local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]\n if nil ~= maxExpiration then\n redis.call("PEXPIREAT", key, maxExpiration)\n end\n\n return true\n "; $this->evaluate($script, (string) $key, [$this->getUniqueToken($key)]); } /** * {@inheritdoc} */ public function exists(Key $key): bool { $script = "\n local key = KEYS[1]\n local uniqueToken = ARGV[2]\n\n -- asserts the KEY is compatible with current version (old Symfony <5.2 BC)\n if redis.call("TYPE", key).ok == "string" then\n return false\n end\n\n ".$this->getNowCode()."\n\n -- Remove expired values\n redis.call("ZREMRANGEBYSCORE", key, "-inf", now)\n\n if redis.call("ZSCORE", key, uniqueToken) then\n return true\n end\n\n return false\n "; return (bool) $this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key)]); } private function evaluate(string $script, string $resource, array $args): mixed { if ( $this->redis instanceof \Redis || $this->redis instanceof \RedisCluster || $this->redis instanceof RedisProxy || $this->redis instanceof RedisClusterProxy ) { $this->redis->clearLastError(); $result = $this->redis->eval($script, array_merge([$resource], $args), 1); if (null !== $err = $this->redis->getLastError()) { throw new LockStorageException($err); } return $result; } if ($this->redis instanceof \RedisArray) { $client = $this->redis->_instance($this->redis->_target($resource)); $client->clearLastError(); $result = $client->eval($script, array_merge([$resource], $args), 1); if (null !== $err = $client->getLastError()) { throw new LockStorageException($err); } return $result; } \assert($this->redis instanceof \Predis\ClientInterface); try { return $this->redis->eval(...array_merge([$script, 1, $resource], $args)); } catch (ServerException $e) { throw new LockStorageException($e->getMessage(), $e->getCode(), $e); } } private function getUniqueToken(Key $key): string { if (!$key->hasState(__CLASS__)) { $token = base64_encode(random_bytes(32)); $key->setState(__CLASS__, $token); } return $key->getState(__CLASS__); } private function getNowCode(): string { if (!isset($this->supportTime)) { // Redis < 5.0 does not support TIME (not deterministic) in script. // https://redis.io/commands/eval#replicating-commands-instead-of-scripts // This code asserts TIME can be use, otherwise will fallback to a timestamp generated by the PHP process. $script = "\n local now = redis.call("TIME")\n redis.call("SET", KEYS[1], "1", "PX", 1)\n\n\t return 1\n "; try { $this->supportTime = 1 === $this->evaluate($script, 'symfony_check_support_time', []); } catch (LockStorageException $e) { if (!str_contains($e->getMessage(), 'commands not allowed after non deterministic')) { throw $e; } $this->supportTime = false; } } if ($this->supportTime) { return "\n local now = redis.call("TIME")\n now = now[1] * 1000 + math.floor(now[2] / 1000)\n "; } return "\n local now = tonumber(ARGV[1])\n now = math.floor(now * 1000)\n "; } } __halt_compiler();----SIGNATURE:----B/CmG28YTxWWKy5F1aEE3edmdu3ql0lLOuupL56b+bW+6KH6XyeGdNPjW4EZ7pkmbX79yvyYvQy+gvDmwDgcZAC6SttaOM987wQi0qNNVpZX/rS7xOCX/NUXwleVmCCe5e0m903EF84WuiCip7uLkPGzTgwV8+ROLJoZldC2uPQzj577JFbE6z/y4pMU9di1tfDHgkeqCsw/pZnkO4h1FdTVRkuYiVpD/JGungJRRTQe9Zao2tWtDaMMaa4gMj9AzIb5YRpdUKdZ9j7CcODJit8V1Ou+AFukkwaGax+nOPyEq53tL5o8ehWOIAhY2aATdCiX5if2qEikfk73mI5fH44tL3yXUt5q/w/3AndaEyZ+OmEVRoVu5nMDIna183KMVJd3i7VRI/d9GBXiZM+B9Sipfk6QFTM+oiR19jfJ/6dV+FutZv0v1dwh6zAlkCB1m4/jCiMM4iVgBLmXOLPZOGYKjtn6xi4dDiFk44r4iTXfuISnLcaNiCTuV6FxX8cWT/W1+aPbohR/yfubvJzjAyi3XnH0fdixwmwpzAbXXoiVqFgnkke2DH+C8UkGF+c456vKG6PT0D7sJBUjeYh1Ch1FtIsnb3+JQ623IezW/xSIxTfYC9cWx7kEnX/Zw8f6VzYQCv4cGbjluFKS0SMcjZ6cr+lZetWhbwQaEi+9k4U=----ATTACHMENT:----OTUzMjQyMDkyMjUyNDI2MSA1NTQ1MDQ4NTA5Mzg5NzA0IDM1ODIxODc5NTc4NjI0NjQ=