/
githubmirror
/
symfony
Обзор
Документация
Войти
/
githubmirror
/
symfony
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
8.2
src/Symfony/Component/Lock/Store/PdoStore.php
305 строк
13 KB
Nicolas Grekas
[Lock] Fix merge up
29 апр 2026, 19:52
29 апр 2026, 19:52
be3a845
Код
Авторство
О чём код?
<?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 Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Exception\InvalidTtlException; use Symfony\Component\Lock\Exception\LockConflictedException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\PersistingStoreInterface; /** * PdoStore is a PersistingStoreInterface implementation using a PDO connection. * * Lock metadata are stored in a table. You can use createTable() to initialize * a correctly defined table. * * CAUTION: This store relies on all client and server nodes to have * synchronized clocks for lock expiry to occur at the correct time. * To ensure locks don't expire prematurely; the TTLs should be set with enough * extra time to account for any clock drift between nodes. * * @author Jérémy Derussé <jeremy@derusse.com> */ class PdoStore implements PersistingStoreInterface { use DatabaseTableTrait; use ExpiringStoreTrait; private \PDO $conn; private string $dsn; private string $driver; private ?string $username = null; private ?string $password = null; private array $connectionOptions = []; /** * You can either pass an existing database connection as PDO instance * or a DSN string that will be used to lazy-connect to the database * when the lock is actually used. * * List of available options: * * db_table: The name of the table [default: lock_keys] * * db_id_col: The column where to store the lock key [default: key_id] * * db_token_col: The column where to store the lock token [default: key_token] * * db_expiration_col: The column where to store the expiration [default: key_expiration] * * db_username: The username when lazy-connect [default: ''] * * db_password: The password when lazy-connect [default: ''] * * db_connection_options: An array of driver-specific connection options [default: []] * * @param array $options An associative array of options * @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks * @param int $initialTtl The expiration delay of locks in seconds * * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION * @throws InvalidArgumentException When the initial ttl is not valid */ public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, #[\SensitiveParameter] array $options = [], float $gcProbability = 0.01, int $initialTtl = 300) { $this->init($options, $gcProbability, $initialTtl); if ($connOrDsn instanceof \PDO) { if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { throw new InvalidArgumentException(\sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)).', __METHOD__)); } $this->conn = $connOrDsn; } else { $this->dsn = $connOrDsn; } $this->username = $options['db_username'] ?? $this->username; $this->password = $options['db_password'] ?? $this->password; $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions; } public function save(Key $key): void { $key->reduceLifetime($this->initialTtl); if ('pgsql' === $this->getDriver()) { $this->doSavePostgres($key); } else { $this->doSave($key); } $this->randomlyPrune(); $this->checkNotExpired($key); } private function doSave(Key $key): void { $sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, {$this->getCurrentTimestampStatement()} + $this->initialTtl)"; $conn = $this->getConnection(); try { $stmt = $conn->prepare($sql); } catch (\PDOException $e) { if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($this->getDriver(), ['sqlite', 'sqlsrv'], true))) { $this->createTable(); } $stmt = $conn->prepare($sql); } $stmt->bindValue(':id', $this->getKeyName($key)); $stmt->bindValue(':token', $this->getUniqueToken($key)); try { $stmt->execute(); } catch (\PDOException $e) { if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($this->getDriver(), ['sqlite', 'sqlsrv'], true))) { $this->createTable(); try { $stmt->execute(); } catch (\PDOException) { $this->putOffExpiration($key, $this->initialTtl); } } else { // the lock is already acquired. It could be us. Let's try to put off. $this->putOffExpiration($key, $this->initialTtl); } } } /** * On PostgreSQL a constraint violation aborts the surrounding transaction (SQLSTATE 25P02), so * the legacy "try INSERT, catch, fall back to UPDATE" path turns a benign lock contention into * a fatal error for any caller wrapped in a transaction. Using atomic INSERT ... ON CONFLICT * keeps the conflict resolution server-side and never raises, preserving the outer transaction. */ private function doSavePostgres(Key $key): void { $now = $this->getCurrentTimestampStatement(); $sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, $now + $this->initialTtl)" ." ON CONFLICT ($this->idCol) DO UPDATE SET $this->tokenCol = EXCLUDED.$this->tokenCol, $this->expirationCol = EXCLUDED.$this->expirationCol" ." WHERE $this->table.$this->tokenCol = EXCLUDED.$this->tokenCol OR $this->table.$this->expirationCol <= $now"; $conn = $this->getConnection(); $id = $this->getKeyName($key); $token = $this->getUniqueToken($key); try { $stmt = $conn->prepare($sql); } catch (\PDOException $e) { if (!$this->isTableMissing($e)) { throw $e; } // PostgreSQL supports DDL inside a transaction, so we can always create the table. $this->createTable(); $stmt = $conn->prepare($sql); } $stmt->bindValue(':id', $id); $stmt->bindValue(':token', $token); try { $stmt->execute(); } catch (\PDOException $e) { // Emulated prepares surface 42P01 at execute() time. if (!$this->isTableMissing($e)) { throw $e; } $this->createTable(); $stmt = $conn->prepare($sql); $stmt->bindValue(':id', $id); $stmt->bindValue(':token', $token); $stmt->execute(); } if (0 === $stmt->rowCount()) { throw new LockConflictedException(); } } 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)); } $key->reduceLifetime($ttl); $sql = "UPDATE $this->table SET $this->expirationCol = {$this->getCurrentTimestampStatement()} + $ttl, $this->tokenCol = :token1 WHERE $this->idCol = :id AND ($this->tokenCol = :token2 OR $this->expirationCol <= {$this->getCurrentTimestampStatement()})"; $stmt = $this->getConnection()->prepare($sql); $uniqueToken = $this->getUniqueToken($key); $stmt->bindValue(':id', $this->getKeyName($key)); $stmt->bindValue(':token1', $uniqueToken); $stmt->bindValue(':token2', $uniqueToken); $result = $stmt->execute(); // If this method is called twice in the same second, the row wouldn't be updated. We have to call exists to know if we are the owner if (!(\is_object($result) ? $result : $stmt)->rowCount() && !$this->exists($key)) { throw new LockConflictedException(); } $this->checkNotExpired($key); } public function delete(Key $key): void { $sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token"; $stmt = $this->getConnection()->prepare($sql); $stmt->bindValue(':id', $this->getKeyName($key)); $stmt->bindValue(':token', $this->getUniqueToken($key)); $stmt->execute(); } public function exists(Key $key): bool { $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token AND $this->expirationCol > {$this->getCurrentTimestampStatement()}"; $stmt = $this->getConnection()->prepare($sql); $stmt->bindValue(':id', $this->getKeyName($key)); $stmt->bindValue(':token', $this->getUniqueToken($key)); $result = $stmt->execute(); return (bool) (\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn()); } private function getConnection(): \PDO { if (!isset($this->conn)) { $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); } return $this->conn; } /** * Creates the table to store lock keys which can be called once for setup. * * @throws \PDOException When the table already exists * @throws \DomainException When an unsupported PDO driver is used */ public function createTable(): void { $sql = match ($driver = $this->getDriver()) { 'mysql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(44) NOT NULL, $this->expirationCol INTEGER UNSIGNED NOT NULL) ENGINE = InnoDB", 'sqlite' => "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->tokenCol TEXT NOT NULL, $this->expirationCol INTEGER)", 'pgsql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)", 'oci' => "CREATE TABLE $this->table ($this->idCol VARCHAR2(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR2(64) NOT NULL, $this->expirationCol INTEGER)", 'sqlsrv' => "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)", default => throw new \DomainException(\sprintf('Creating the lock table is currently not implemented for platform "%s".', $driver)), }; $this->getConnection()->exec($sql); } /** * Cleans up the table by removing all expired locks. */ private function prune(): void { $sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatement()}"; $this->getConnection()->exec($sql); } private function getDriver(): string { return $this->driver ??= $this->getConnection()->getAttribute(\PDO::ATTR_DRIVER_NAME); } /** * Provides an SQL function to get the current timestamp regarding the current connection's driver. */ private function getCurrentTimestampStatement(): string { return match ($this->getDriver()) { 'mysql' => 'UNIX_TIMESTAMP(NOW(6))', 'sqlite' => "(julianday('now') - 2440587.5) * 86400.0", 'pgsql' => 'CAST(EXTRACT(epoch FROM NOW()) AS DOUBLE PRECISION)', 'oci' => "(CAST(systimestamp AT TIME ZONE 'UTC' AS DATE) - DATE '1970-01-01') * 86400 + TO_NUMBER(TO_CHAR(systimestamp AT TIME ZONE 'UTC', 'SSSSS.FF'))", 'sqlsrv' => "CAST(DATEDIFF_BIG(ms, '1970-01-01', SYSUTCDATETIME()) AS FLOAT) / 1000.0", default => (new \DateTimeImmutable())->format('U.u'), }; } private function isTableMissing(\PDOException $exception): bool { $driver = $this->getDriver(); [$sqlState, $code] = $exception->errorInfo ?? [null, $exception->getCode()]; return match ($driver) { 'pgsql' => '42P01' === $sqlState, 'sqlite' => str_contains($exception->getMessage(), 'no such table:'), 'oci' => 942 === $code, 'sqlsrv' => 208 === $code, 'mysql' => 1146 === $code, default => false, }; } }