From 46fe1b07121f97b812fef1f4076a564c5cd0c720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Thu, 31 May 2018 23:23:18 +0200 Subject: [PATCH] Add a PdoStore in lock --- src/Symfony/Component/Lock/CHANGELOG.md | 5 + .../Component/Lock/Store/MemcachedStore.php | 15 +- src/Symfony/Component/Lock/Store/PdoStore.php | 361 ++++++++++++++++++ .../Component/Lock/Store/RedisStore.php | 13 +- .../Tests/Store/BlockingStoreTestTrait.php | 7 +- .../Tests/Store/ExpiringStoreTestTrait.php | 2 +- .../Lock/Tests/Store/PdoDbalStoreTest.php | 61 +++ .../Lock/Tests/Store/PdoStoreTest.php | 60 +++ src/Symfony/Component/Lock/composer.json | 3 +- 9 files changed, 506 insertions(+), 21 deletions(-) create mode 100644 src/Symfony/Component/Lock/Store/PdoStore.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 8e992b982a9b..32fd243b8f36 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 fc504ca85647..c9200af19956 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 000000000000..7961cfb86c2e --- /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 6d7958beefb9..4bb59a2e2959 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 1e3436eb5a09..7fbdee614651 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 10b13273870e..e356658f24f4 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 000000000000..e72fbcf76b5e --- /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 000000000000..45e3544e2bf8 --- /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 8aaf0eab94d2..22704c6e5c36 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\\": "" },