/
githubmirror
/
symfony
Обзор
Документация
Войти
/
githubmirror
/
symfony
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
8.2
src/Symfony/Component/Lock/Store/RedisStore.php
379 строк
13 KB
Jan Nedbal
Mark callbacks with @param-immediately-invoked-callable where applicable
05 май 2026, 18:33
05 май 2026, 18:33
24e9c36
Код
Авторство
О чём код?
<?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\Lock\Store; use Predis\Response\Error; use Predis\Response\ServerException; use Relay\Cluster as RelayCluster; use Relay\Relay; use Symfony\Component\Lock\Exception\InvalidTtlException; use Symfony\Component\Lock\Exception\LockConflictedException; use Symfony\Component\Lock\Exception\LockStorageException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\SharedLockStoreInterface; /** * RedisStore is a PersistingStoreInterface implementation using Redis as store engine. * * @author Jérémy Derussé <jeremy@derusse.com> * @author Grégoire Pineau <lyrixx@lyrixx.info> */ class RedisStore implements SharedLockStoreInterface { use ExpiringStoreTrait; private const NO_SCRIPT_ERROR_MESSAGE_PREFIX = 'NOSCRIPT'; private bool $supportTime; /** * @param float $initialTtl The expiration delay of locks in seconds */ public function __construct( private \Redis|Relay|RelayCluster|\RedisArray|\RedisCluster|\Predis\ClientInterface $redis, private float $initialTtl = 300.0, ) { if ($initialTtl <= 0) { throw new InvalidTtlException(\sprintf('"%s()" expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl)); } } public function save(Key $key): void { $script = ' local key = KEYS[1] local uniqueToken = ARGV[2] local ttl = tonumber(ARGV[3]) -- asserts the KEY is compatible with current version (old Symfony <5.2 BC) if redis.call("TYPE", key).ok == "string" then return false end '.$this->getNowCode().' -- Remove expired values redis.call("ZREMRANGEBYSCORE", key, "-inf", now) -- is already acquired if redis.call("ZSCORE", key, uniqueToken) then -- is not WRITE lock and cannot be promoted if not redis.call("ZSCORE", key, "__write__") and redis.call("ZCOUNT", key, "-inf", "+inf") > 1 then return false end elseif redis.call("ZCOUNT", key, "-inf", "+inf") > 0 then return false end redis.call("ZADD", key, now + ttl, uniqueToken) redis.call("ZADD", key, now + ttl, "__write__") -- Extend the TTL of the key local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2] redis.call("PEXPIREAT", key, maxExpiration) return true '; $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); } public function saveRead(Key $key): void { $script = ' local key = KEYS[1] local uniqueToken = ARGV[2] local ttl = tonumber(ARGV[3]) -- asserts the KEY is compatible with current version (old Symfony <5.2 BC) if redis.call("TYPE", key).ok == "string" then return false end '.$this->getNowCode().' -- Remove expired values redis.call("ZREMRANGEBYSCORE", key, "-inf", now) -- lock not already acquired and a WRITE lock exists? if not redis.call("ZSCORE", key, uniqueToken) and redis.call("ZSCORE", key, "__write__") then return false end redis.call("ZADD", key, now + ttl, uniqueToken) redis.call("ZREM", key, "__write__") -- Extend the TTL of the key local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2] redis.call("PEXPIREAT", key, maxExpiration) return true '; $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); } public function putOffExpiration(Key $key, float $ttl): void { $script = ' local key = KEYS[1] local uniqueToken = ARGV[2] local ttl = tonumber(ARGV[3]) -- asserts the KEY is compatible with current version (old Symfony <5.2 BC) if redis.call("TYPE", key).ok == "string" then return false end '.$this->getNowCode().' -- lock already acquired acquired? if not redis.call("ZSCORE", key, uniqueToken) then return false end redis.call("ZADD", key, now + ttl, uniqueToken) -- if the lock is also a WRITE lock, increase the TTL if redis.call("ZSCORE", key, "__write__") then redis.call("ZADD", key, now + ttl, "__write__") end -- Extend the TTL of the key local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2] redis.call("PEXPIREAT", key, maxExpiration) return true '; $key->reduceLifetime($ttl); if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($ttl * 1000)])) { throw new LockConflictedException(); } $this->checkNotExpired($key); } public function delete(Key $key): void { $script = ' local key = KEYS[1] local uniqueToken = ARGV[1] -- asserts the KEY is compatible with current version (old Symfony <5.2 BC) if redis.call("TYPE", key).ok == "string" then return false end -- lock not already acquired if not redis.call("ZSCORE", key, uniqueToken) then return false end redis.call("ZREM", key, uniqueToken) redis.call("ZREM", key, "__write__") local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2] if nil ~= maxExpiration then redis.call("PEXPIREAT", key, maxExpiration) end return true '; $this->evaluate($script, (string) $key, [$this->getUniqueToken($key)]); } public function exists(Key $key): bool { $script = ' local key = KEYS[1] local uniqueToken = ARGV[2] -- asserts the KEY is compatible with current version (old Symfony <5.2 BC) if redis.call("TYPE", key).ok == "string" then return false end '.$this->getNowCode().' -- Remove expired values redis.call("ZREMRANGEBYSCORE", key, "-inf", now) if redis.call("ZSCORE", key, uniqueToken) then return true end return false '; return (bool) $this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key)]); } private function evaluate(string $script, string $resource, array $args): mixed { $scriptSha = sha1($script); if ($this->redis instanceof \Redis || $this->redis instanceof Relay || $this->redis instanceof RelayCluster || $this->redis instanceof \RedisCluster) { $this->redis->clearLastError(); $result = $this->redis->evalSha($scriptSha, array_merge([$resource], $args), 1); if (null !== ($err = $this->redis->getLastError()) && str_starts_with($err, self::NO_SCRIPT_ERROR_MESSAGE_PREFIX)) { $this->redis->clearLastError(); if ($this->redis instanceof \RedisCluster || $this->redis instanceof RelayCluster) { foreach ($this->redis->_masters() as $master) { $this->redis->script($master, 'LOAD', $script); } } else { $this->redis->script('LOAD', $script); } if (null !== $err = $this->redis->getLastError()) { throw new LockStorageException($err); } $result = $this->redis->evalSha($scriptSha, 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->evalSha($scriptSha, array_merge([$resource], $args), 1); if (null !== ($err = $client->getLastError()) && str_starts_with($err, self::NO_SCRIPT_ERROR_MESSAGE_PREFIX)) { $client->clearLastError(); $client->script('LOAD', $script); if (null !== $err = $client->getLastError()) { throw new LockStorageException($err); } $result = $client->evalSha($scriptSha, 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->handlePredisError(fn () => $this->redis->evalSha($scriptSha, 1, $resource, ...$args)); } catch (LockStorageException $e) { // Fallthrough only if we need to load the script if (!str_starts_with($e->getMessage(), self::NO_SCRIPT_ERROR_MESSAGE_PREFIX)) { throw $e; } } if ($this->redis->getConnection() instanceof \Predis\Connection\Cluster\ClusterInterface) { foreach ($this->redis as $connection) { $this->handlePredisError(static fn () => $connection->script('LOAD', $script)); } } else { $this->handlePredisError(fn () => $this->redis->script('LOAD', $script)); } return $this->handlePredisError(fn () => $this->redis->evalSha($scriptSha, 1, $resource, ...$args)); } 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 = ' local now = redis.call("TIME") redis.call("SET", KEYS[1], "1", "PX", 1) return 1 '; 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') && !str_contains($e->getMessage(), 'is not allowed from script script') ) { throw $e; } $this->supportTime = false; } } if ($this->supportTime) { return ' local now = redis.call("TIME") now = now[1] * 1000 + math.floor(now[2] / 1000) '; } return ' local now = tonumber(ARGV[1]) now = math.floor(now * 1000) '; } /** * @template T * * @param-immediately-invoked-callable $callback * * @param callable(): T $callback * * @return T */ private function handlePredisError(callable $callback): mixed { try { $result = $callback(); } catch (ServerException $e) { throw new LockStorageException($e->getMessage(), $e->getCode(), $e); } if ($result instanceof Error) { throw new LockStorageException($result->getMessage()); } return $result; } }