diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 8e992b982a9b9..32fd243b8f363 100644 --- a/src/Symfony/Component/Lock/CHANGELOG.md +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +4.2.0 +----- + + * added the PDO Store + 3.4.0 ----- diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php index fc504ca856472..c9200af199564 100644 --- a/src/Symfony/Component/Lock/Store/MemcachedStore.php +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -57,7 +57,7 @@ public function __construct(\Memcached $memcached, int $initialTtl = 300) */ public function save(Key $key) { - $token = $this->getToken($key); + $token = $this->getUniqueToken($key); $key->reduceLifetime($this->initialTtl); if (!$this->memcached->add((string) $key, $token, (int) ceil($this->initialTtl))) { // the lock is already acquired. It could be us. Let's try to put off. @@ -80,13 +80,13 @@ public function waitAndSave(Key $key) public function putOffExpiration(Key $key, $ttl) { if ($ttl < 1) { - throw new InvalidArgumentException(sprintf('%s() expects a TTL greater or equals to 1. Got %s.', __METHOD__, $ttl)); + throw new InvalidArgumentException(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. $ttl = (int) ceil($ttl); - $token = $this->getToken($key); + $token = $this->getUniqueToken($key); list($value, $cas) = $this->getValueAndCas($key); @@ -120,7 +120,7 @@ public function putOffExpiration(Key $key, $ttl) */ public function delete(Key $key) { - $token = $this->getToken($key); + $token = $this->getUniqueToken($key); list($value, $cas) = $this->getValueAndCas($key); @@ -144,13 +144,10 @@ public function delete(Key $key) */ public function exists(Key $key) { - return $this->memcached->get((string) $key) === $this->getToken($key); + return $this->memcached->get((string) $key) === $this->getUniqueToken($key); } - /** - * Retrieve an unique token for the given key. - */ - private function getToken(Key $key): string + private function getUniqueToken(Key $key): string { if (!$key->hasState(__CLASS__)) { $token = base64_encode(random_bytes(32)); diff --git a/src/Symfony/Component/Lock/Store/PdoStore.php b/src/Symfony/Component/Lock/Store/PdoStore.php new file mode 100644 index 0000000000000..7961cfb86c2e7 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/PdoStore.php @@ -0,0 +1,361 @@ + + * + * 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 Doctrine\DBAL\Connection; +use Doctrine\DBAL\DBALException; +use Doctrine\DBAL\Schema\Schema; +use Symfony\Component\Lock\Exception\InvalidArgumentException; +use Symfony\Component\Lock\Exception\LockConflictedException; +use Symfony\Component\Lock\Exception\LockExpiredException; +use Symfony\Component\Lock\Exception\NotSupportedException; +use Symfony\Component\Lock\Key; +use Symfony\Component\Lock\StoreInterface; + +/** + * PdoStore is a StoreInterface 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 ttl's should be set with enough + * extra time to account for any clock drift between nodes. + * + * @author Jérémy Derussé + */ +class PdoStore implements StoreInterface +{ + private $conn; + private $dsn; + private $driver; + private $table = 'lock_keys'; + private $idCol = 'key_id'; + private $tokenCol = 'key_token'; + private $expirationCol = 'key_expiration'; + private $username = ''; + private $password = ''; + private $connectionOptions = array(); + private $gcProbability; + private $initialTtl; + + /** + * You can either pass an existing database connection as PDO instance or + * a Doctrine DBAL Connection 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: array()] + * + * @param \PDO|Connection|string $connOrDsn A \PDO or Connection instance or DSN string or null + * @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 namespace contains invalid characters + * @throws InvalidArgumentException When the initial ttl is not valid + */ + public function __construct($connOrDsn, array $options = array(), float $gcProbability = 0.01, int $initialTtl = 300) + { + if ($gcProbability < 0 || $gcProbability > 1) { + throw new InvalidArgumentException(sprintf('"%s" requires gcProbability between 0 and 1, "%f" given.', __METHOD__, $gcProbability)); + } + if ($initialTtl < 1) { + throw new InvalidArgumentException(sprintf('%s() expects a strictly positive TTL. Got %d.', __METHOD__, $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; + } elseif ($connOrDsn instanceof Connection) { + $this->conn = $connOrDsn; + } elseif (\is_string($connOrDsn)) { + $this->dsn = $connOrDsn; + } else { + throw new InvalidArgumentException(sprintf('"%s" requires PDO or Doctrine\DBAL\Connection instance or DSN string as first argument, "%s" given.', __CLASS__, \is_object($connOrDsn) ? \get_class($connOrDsn) : \gettype($connOrDsn))); + } + + $this->table = $options['db_table'] ?? $this->table; + $this->idCol = $options['db_id_col'] ?? $this->idCol; + $this->tokenCol = $options['db_token_col'] ?? $this->tokenCol; + $this->expirationCol = $options['db_expiration_col'] ?? $this->expirationCol; + $this->username = $options['db_username'] ?? $this->username; + $this->password = $options['db_password'] ?? $this->password; + $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions; + + $this->gcProbability = $gcProbability; + $this->initialTtl = $initialTtl; + } + + /** + * {@inheritdoc} + */ + public function save(Key $key) + { + $key->reduceLifetime($this->initialTtl); + + $sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, {$this->getCurrentTimestampStatement()} + $this->initialTtl)"; + $stmt = $this->getConnection()->prepare($sql); + + $stmt->bindValue(':id', $this->getHashedKey($key)); + $stmt->bindValue(':token', $this->getUniqueToken($key)); + + try { + $stmt->execute(); + if ($key->isExpired()) { + throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key)); + } + + return; + } catch (DBALException $e) { + // the lock is already acquired. It could be us. Let's try to put off. + $this->putOffExpiration($key, $this->initialTtl); + } catch (\PDOException $e) { + // the lock is already acquired. It could be us. Let's try to put off. + $this->putOffExpiration($key, $this->initialTtl); + } + + if ($key->isExpired()) { + throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key)); + } + + if ($this->gcProbability > 0 && (1.0 === $this->gcProbability || (random_int(0, PHP_INT_MAX) / PHP_INT_MAX) <= $this->gcProbability)) { + $this->prune(); + } + } + + /** + * {@inheritdoc} + */ + public function waitAndSave(Key $key) + { + throw new NotSupportedException(sprintf('The store "%s" does not supports blocking locks.', __METHOD__)); + } + + /** + * {@inheritdoc} + */ + public function putOffExpiration(Key $key, $ttl) + { + if ($ttl < 1) { + throw new InvalidArgumentException(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 = :token WHERE $this->idCol = :id AND ($this->tokenCol = :token OR $this->expirationCol <= {$this->getCurrentTimestampStatement()})"; + $stmt = $this->getConnection()->prepare($sql); + + $stmt->bindValue(':id', $this->getHashedKey($key)); + $stmt->bindValue(':token', $this->getUniqueToken($key)); + $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 (!$stmt->rowCount() && !$this->exists($key)) { + throw new LockConflictedException(); + } + + if ($key->isExpired()) { + throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key)); + } + } + + /** + * {@inheritdoc} + */ + public function delete(Key $key) + { + $sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token"; + $stmt = $this->getConnection()->prepare($sql); + + $stmt->bindValue(':id', $this->getHashedKey($key)); + $stmt->bindValue(':token', $this->getUniqueToken($key)); + $stmt->execute(); + } + + /** + * {@inheritdoc} + */ + public function exists(Key $key) + { + $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->getHashedKey($key)); + $stmt->bindValue(':token', $this->getUniqueToken($key)); + $stmt->execute(); + + return (bool) $stmt->fetchColumn(); + } + + /** + * Returns an hashed version of the key. + */ + private function getHashedKey(Key $key): string + { + return hash('sha256', $key); + } + + private function getUniqueToken(Key $key): string + { + if (!$key->hasState(__CLASS__)) { + $token = base64_encode(random_bytes(32)); + $key->setState(__CLASS__, $token); + } + + return $key->getState(__CLASS__); + } + + /** + * @return \PDO|Connection + */ + private function getConnection() + { + if (null === $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 DBALException When the table already exists + * @throws \DomainException When an unsupported PDO driver is used + */ + public function createTable(): void + { + // connect if we are not yet + $conn = $this->getConnection(); + $driver = $this->getDriver(); + + if ($conn instanceof Connection) { + $schema = new Schema(); + $table = $schema->createTable($this->table); + $table->addColumn($this->idCol, 'string', array('length' => 64)); + $table->addColumn($this->tokenCol, 'string', array('length' => 44)); + $table->addColumn($this->expirationCol, 'integer', array('unsigned' => true)); + $table->setPrimaryKey(array($this->idCol)); + + foreach ($schema->toSql($conn->getDatabasePlatform()) as $sql) { + $conn->exec($sql); + } + + return; + } + + switch ($driver) { + case 'mysql': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(44) NOT NULL, $this->expirationCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; + break; + case 'sqlite': + $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->tokenCol TEXT NOT NULL, $this->expirationCol INTEGER)"; + break; + case 'pgsql': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)"; + break; + case 'oci': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR2(64) NOT NULL, $this->expirationCol INTEGER)"; + break; + case 'sqlsrv': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)"; + break; + default: + throw new \DomainException(sprintf('Creating the lock table is currently not implemented for PDO driver "%s".', $driver)); + } + + $conn->exec($sql); + } + + /** + * Cleanups the table by removing all expired locks. + */ + private function prune(): void + { + $sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatement()}"; + + $stmt = $this->getConnection()->prepare($sql); + + $stmt->execute(); + } + + private function getDriver(): string + { + if (null !== $this->driver) { + return $this->driver; + } + + $con = $this->getConnection(); + if ($con instanceof \PDO) { + $this->driver = $con->getAttribute(\PDO::ATTR_DRIVER_NAME); + } else { + switch ($this->driver = $con->getDriver()->getName()) { + case 'mysqli': + case 'pdo_mysql': + case 'drizzle_pdo_mysql': + $this->driver = 'mysql'; + break; + case 'pdo_sqlite': + $this->driver = 'sqlite'; + break; + case 'pdo_pgsql': + $this->driver = 'pgsql'; + break; + case 'oci8': + case 'pdo_oracle': + $this->driver = 'oci'; + break; + case 'pdo_sqlsrv': + $this->driver = 'sqlsrv'; + break; + } + } + + return $this->driver; + } + + /** + * Provides a SQL function to get the current timestamp regarding the current connection's driver. + */ + private function getCurrentTimestampStatement(): string + { + switch ($this->getDriver()) { + case 'mysql': + return 'UNIX_TIMESTAMP()'; + case 'sqlite': + return 'strftime(\'%s\',\'now\')'; + case 'pgsql': + return 'CAST(EXTRACT(epoch FROM NOW()) AS INT)'; + case 'oci': + return '(SYSDATE - TO_DATE(\'19700101\',\'yyyymmdd\'))*86400 - TO_NUMBER(SUBSTR(TZ_OFFSET(sessiontimezone), 1, 3))*3600'; + case 'sqlsrv': + return 'DATEDIFF(s, \'1970-01-01\', GETUTCDATE())'; + default: + return time(); + } + } +} diff --git a/src/Symfony/Component/Lock/Store/RedisStore.php b/src/Symfony/Component/Lock/Store/RedisStore.php index 6d7958beefb92..4bb59a2e2959e 100644 --- a/src/Symfony/Component/Lock/Store/RedisStore.php +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -60,7 +60,7 @@ public function save(Key $key) '; $key->reduceLifetime($this->initialTtl); - if (!$this->evaluate($script, (string) $key, array($this->getToken($key), (int) ceil($this->initialTtl * 1000)))) { + if (!$this->evaluate($script, (string) $key, array($this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)))) { throw new LockConflictedException(); } @@ -88,7 +88,7 @@ public function putOffExpiration(Key $key, $ttl) '; $key->reduceLifetime($ttl); - if (!$this->evaluate($script, (string) $key, array($this->getToken($key), (int) ceil($ttl * 1000)))) { + if (!$this->evaluate($script, (string) $key, array($this->getUniqueToken($key), (int) ceil($ttl * 1000)))) { throw new LockConflictedException(); } @@ -110,7 +110,7 @@ public function delete(Key $key) end '; - $this->evaluate($script, (string) $key, array($this->getToken($key))); + $this->evaluate($script, (string) $key, array($this->getUniqueToken($key))); } /** @@ -118,7 +118,7 @@ public function delete(Key $key) */ public function exists(Key $key) { - return $this->redis->get((string) $key) === $this->getToken($key); + return $this->redis->get((string) $key) === $this->getUniqueToken($key); } /** @@ -143,10 +143,7 @@ private function evaluate(string $script, string $resource, array $args) throw new InvalidArgumentException(sprintf('%s() expects been initialized with a Redis, RedisArray, RedisCluster or Predis\Client, %s given', __METHOD__, \is_object($this->redis) ? \get_class($this->redis) : \gettype($this->redis))); } - /** - * Retrieves an unique token for the given key. - */ - private function getToken(Key $key): string + private function getUniqueToken(Key $key): string { if (!$key->hasState(__CLASS__)) { $token = base64_encode(random_bytes(32)); diff --git a/src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php b/src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php index 1e3436eb5a094..7fbdee614651a 100644 --- a/src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php +++ b/src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php @@ -22,6 +22,8 @@ trait BlockingStoreTestTrait { /** * @see AbstractStoreTest::getStore() + * + * @return StoreInterface */ abstract protected function getStore(); @@ -39,8 +41,6 @@ public function testBlockingLocks() // Amount a microsecond used to order async actions $clockDelay = 50000; - /** @var StoreInterface $store */ - $store = $this->getStore(); $key = new Key(uniqid(__METHOD__, true)); $parentPID = posix_getpid(); @@ -51,6 +51,7 @@ public function testBlockingLocks() // Wait the start of the child pcntl_sigwaitinfo(array(SIGHUP), $info); + $store = $this->getStore(); try { // This call should failed given the lock should already by acquired by the child $store->save($key); @@ -72,6 +73,8 @@ public function testBlockingLocks() } else { // Block SIGHUP signal pcntl_sigprocmask(SIG_BLOCK, array(SIGHUP)); + + $store = $this->getStore(); try { $store->save($key); // send the ready signal to the parent diff --git a/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php b/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php index 10b13273870e7..e356658f24f48 100644 --- a/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php +++ b/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php @@ -48,7 +48,7 @@ public function testExpiration() $store->putOffExpiration($key, $clockDelay / 1000000); $this->assertTrue($store->exists($key)); - usleep(2 * $clockDelay); + usleep(3 * $clockDelay); $this->assertFalse($store->exists($key)); } diff --git a/src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php new file mode 100644 index 0000000000000..e72fbcf76b5e1 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Tests\Store; + +use Doctrine\DBAL\DriverManager; +use Symfony\Component\Lock\Store\PdoStore; + +/** + * @author Jérémy Derussé + * + * @requires extension pdo_sqlite + */ +class PdoDbalStoreTest extends AbstractStoreTest +{ + use ExpiringStoreTestTrait; + + protected static $dbFile; + + public static function setupBeforeClass() + { + self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_lock'); + + $store = new PdoStore(DriverManager::getConnection(array('driver' => 'pdo_sqlite', 'path' => self::$dbFile))); + $store->createTable(); + } + + public static function tearDownAfterClass() + { + @unlink(self::$dbFile); + } + + /** + * {@inheritdoc} + */ + protected function getClockDelay() + { + return 1000000; + } + + /** + * {@inheritdoc} + */ + public function getStore() + { + return new PdoStore(DriverManager::getConnection(array('driver' => 'pdo_sqlite', 'path' => self::$dbFile))); + } + + public function testAbortAfterExpiration() + { + $this->markTestSkipped('Pdo expects a TTL greater than 1 sec. Simulating a slow network is too hard'); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php new file mode 100644 index 0000000000000..45e3544e2bf82 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Tests\Store; + +use Symfony\Component\Lock\Store\PdoStore; + +/** + * @author Jérémy Derussé + * + * @requires extension pdo_sqlite + */ +class PdoStoreTest extends AbstractStoreTest +{ + use ExpiringStoreTestTrait; + + protected static $dbFile; + + public static function setupBeforeClass() + { + self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_lock'); + + $store = new PdoStore('sqlite:'.self::$dbFile); + $store->createTable(); + } + + public static function tearDownAfterClass() + { + @unlink(self::$dbFile); + } + + /** + * {@inheritdoc} + */ + protected function getClockDelay() + { + return 1000000; + } + + /** + * {@inheritdoc} + */ + public function getStore() + { + return new PdoStore('sqlite:'.self::$dbFile); + } + + public function testAbortAfterExpiration() + { + $this->markTestSkipped('Pdo expects a TTL greater than 1 sec. Simulating a slow network is too hard'); + } +} diff --git a/src/Symfony/Component/Lock/composer.json b/src/Symfony/Component/Lock/composer.json index 8aaf0eab94d25..22704c6e5c361 100644 --- a/src/Symfony/Component/Lock/composer.json +++ b/src/Symfony/Component/Lock/composer.json @@ -20,7 +20,8 @@ "psr/log": "~1.0" }, "require-dev": { - "predis/predis": "~1.0" + "predis/predis": "~1.0", + "doctrine/dbal": "~2.4" }, "autoload": { "psr-4": { "Symfony\\Component\\Lock\\": "" }, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy