Skip to content

[Lock] Add MysqlStore that use GET_LOCK #25578

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ cache:
services:
- memcached
- mongodb
- mysql
- redis-server

before_install:
Expand Down
4 changes: 4 additions & 0 deletions appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ build: false
clone_depth: 1
clone_folder: c:\projects\symfony

services:
- mysql

cache:
- composer.phar
- .phpunit -> phpunit
Expand Down Expand Up @@ -35,6 +38,7 @@ install:
- echo extension=php_intl.dll >> php.ini-max
- echo extension=php_mbstring.dll >> php.ini-max
- echo extension=php_fileinfo.dll >> php.ini-max
- echo extension=php_pdo_mysql.dll >> php.ini-max
- echo extension=php_pdo_sqlite.dll >> php.ini-max
- echo extension=php_curl.dll >> php.ini-max
- copy /Y php.ini-max php.ini
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
<env name="LDAP_PORT" value="3389" />
<env name="REDIS_HOST" value="localhost" />
<env name="MEMCACHED_HOST" value="localhost" />
<env name="MYSQL_HOST" value="127.0.0.1" />
<env name="MYSQL_USERNAME" value="root" />
<env name="MYSQL_PASSWORD" value="" />
</php>

<testsuites>
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/Lock/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

4.1.0
-----

* added Mysql store using GET_LOCK

3.4.0
-----

Expand Down
158 changes: 158 additions & 0 deletions src/Symfony/Component/Lock/Store/MysqlStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?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 Doctrine\DBAL\Connection;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockAcquiringException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;

/**
* MysqlStore is a StoreInterface implementation using MySQL/MariaDB GET_LOCK function.
*
* @author Jérôme TAMARELLE <jerome@tamarelle.net>
*/
class MysqlStore implements StoreInterface
{
/**
* @var \PDO|Connection
*/
private $connection;
private $waitTimeout;

/**
* @param \PDO|Connection $connection
* @param int $waitTimeout Time in seconds to wait for a lock to be released, for non-blocking lock.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be acquired, and not to be released right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be released after the timeout so that sounds right to me. You could also say "The time in seconds the lock is acquired for".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the documentaiton the parameter timeout, define the maximum amount of time allowed to acquire the lock. Once you get it, you can keep it as long as you want.

T0: ask for acquiring a new lock (wait for other process to release the lock for instance)
T1: lock is acquired
T2: lock is released

Here we are talking about the time between T1 and T0.

*/
public function __construct($connection, $waitTimeout = 0)
{
if ($connection instanceof \PDO) {
if ('mysql' !== $driver = $connection->getAttribute(\PDO::ATTR_DRIVER_NAME)) {
throw new InvalidArgumentException(sprintf('%s requires a "mysql" connection. "%s" given.', __CLASS__, $driver));
}
} elseif ($connection instanceof Connection) {
if ('pdo_mysql' !== $driver = $connection->getDriver()->getName()) {
throw new InvalidArgumentException(sprintf('%s requires a "pdo_mysql" connection. "%s" given.', __CLASS__, $driver));
}
} else {
throw new InvalidArgumentException(sprintf('"%s" requires PDO or Doctrine\DBAL\Connection instance, "%s" given.', __CLASS__, is_object($connection) ? get_class($connection) : gettype($connection)));
}

if ($waitTimeout < 0) {
throw new InvalidArgumentException(sprintf('"%s" requires a positive wait timeout, "%d" given. For infine wait, acquire a "blocking" lock.', __CLASS__, $waitTimeout));
}

$this->connection = $connection;
$this->waitTimeout = $waitTimeout;
}

/**
* {@inheritdoc}
*/
public function save(Key $key)
{
$this->lock($key, false);
}

/**
* {@inheritdoc}
*/
public function waitAndSave(Key $key)
{
$this->lock($key, true);
}

private function lock(Key $key, bool $blocking)
{
// the lock is maybe already acquired.
if ($key->hasState(__CLASS__)) {
return;
}

// no timeout for impatient
$timeout = $blocking ? -1 : $this->waitTimeout;

// Hash the key to guarantee it contains between 1 and 64 characters
$storedKey = hash('sha256', $key);

$stmt = $this->connection->prepare('SELECT IF(IS_USED_LOCK(:key) = CONNECTION_ID(), -1, GET_LOCK(:key, :timeout))');
$stmt->bindValue(':key', $storedKey, \PDO::PARAM_STR);
$stmt->bindValue(':timeout', $timeout, \PDO::PARAM_INT);
$stmt->setFetchMode(\PDO::FETCH_COLUMN, 0);
$stmt->execute();

// 1: Lock successful
// 0: Already locked by another session
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null, process killed by admin, or machine out of memory

// -1: Already locked by the same session
$success = $stmt->fetchColumn();

if ('-1' === $success) {
throw new LockConflictedException('Lock already acquired with by same connection.');
}

if ('1' !== $success) {
throw new LockConflictedException();
}

$key->setState(__CLASS__, $storedKey);
}

/**
* {@inheritdoc}
*/
public function putOffExpiration(Key $key, $ttl)
{
// the GET_LOCK locks forever, until the session terminates.
$stmt = $this->connection->prepare('SET SESSION wait_timeout=GREATEST(@@wait_timeout, :ttl)');
$stmt->bindValue(':ttl', $ttl, \PDO::PARAM_INT);
$stmt->execute();
}

/**
* {@inheritdoc}
*/
public function delete(Key $key)
{
if (!$key->hasState(__CLASS__)) {
return;
}

$storedKey = $key->getState(__CLASS__);

$stmt = $this->connection->prepare('DO RELEASE_LOCK(:key)');
$stmt->bindValue(':key', $storedKey, \PDO::PARAM_STR);
$stmt->execute();

$key->removeState(__CLASS__);
}

/**
* {@inheritdoc}
*/
public function exists(Key $key)
{
if (!$key->hasState(__CLASS__)) {
return false;
}

$storedKey = $key->getState(__CLASS__);

$stmt = $this->connection->prepare('SELECT IF(IS_USED_LOCK(:key) = CONNECTION_ID(), 1, 0)');
Copy link
Member Author

@GromNaN GromNaN Jan 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jderusse This query insure that: 1. the connection is still alive & 2. the lock is still assigned to the current connection.

$stmt->bindValue(':key', $storedKey, \PDO::PARAM_STR);
$stmt->setFetchMode(\PDO::FETCH_COLUMN, 0);
$stmt->execute();

return '1' === $stmt->fetchColumn();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ trait BlockingStoreTestTrait
{
/**
* @see AbstractStoreTest::getStore()
*
* @return StoreInterface
*/
abstract protected function getStore();

Expand All @@ -38,8 +40,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();

Expand All @@ -50,6 +50,7 @@ public function testBlockingLocks()
// Wait the start of the child
pcntl_sigwaitinfo(array(SIGHUP), $info);

$store = $this->getStore();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

try {
// This call should failed given the lock should already by acquired by the child
$store->save($key);
Expand All @@ -71,6 +72,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
Expand Down
117 changes: 117 additions & 0 deletions src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?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\Tests\Store;

use Doctrine\DBAL\DriverManager;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\Store\MysqlStore;

/**
* @author Jérôme TAMARELLE <jerome@tamarelle.net>
*/
class MysqlStoreTest extends AbstractStoreTest
{
use BlockingStoreTestTrait;

private $connectionCase = 'pdo';

/**
* {@inheritdoc}
*/
public function getStore()
{
switch ($this->connectionCase) {
case 'pdo':
$connection = new \PDO('mysql:host='.getenv('MYSQL_HOST'), getenv('MYSQL_USERNAME'), getenv('MYSQL_PASSWORD'));
$connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
break;

case 'dbal':
$connection = DriverManager::getConnection(array(
'driver' => 'pdo_mysql',
'user' => getenv('MYSQL_USERNAME'),
'password' => getenv('MYSQL_PASSWORD'),
'host' => getenv('MYSQL_HOST'),
));
break;
}

return new MysqlStore($connection);
}

public function testSaveWithDoctrineDBAL()
{
if (!class_exists(DriverManager::class)) {
$this->markTestSkipped('Package doctrine/dbal is required.');
}

$this->connectionCase = 'dbal';

parent::testSave();
}

/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Symfony\Component\Lock\Store\MysqlStore requires a "mysql" connection. "sqlite" given.
*/
public function testOnlyMySQLDatabaseIsSupported()
{
$connection = new \PDO('sqlite::memory:');

return new MysqlStore($connection);
}

/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Symfony\Component\Lock\Store\MysqlStore requires a "pdo_mysql" connection. "pdo_sqlite" given.
*/
public function testOnlyMySQLDatabaseIsSupportedWithDoctrineDBAL()
{
if (!class_exists(DriverManager::class)) {
$this->markTestSkipped('Package doctrine/dbal is required.');
}

$connection = DriverManager::getConnection(array(
'driver' => 'pdo_sqlite',
));

return new MysqlStore($connection);
}

/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage "Symfony\Component\Lock\Store\MysqlStore" requires a positive wait timeout, "-1" given. For infine wait, acquire a "blocking" lock.
*/
public function testOnlyPositiveWaitTimeoutIsSupported()
{
$connection = $this->createMock(\PDO::class);
$connection->method('getAttribute')->willReturn('mysql');

return new MysqlStore($connection, -1);
}

/**
* @expectedException \Symfony\Component\Lock\Exception\LockConflictedException
* @expectedExceptionMessage Lock already acquired with by same connection.
*/
public function testWaitTheSameResourceOnTheSameConnectionIsNotSupported()
{
$store = $this->getStore();

$resource = uniqid(__METHOD__, true);
$key1 = new Key($resource);
$key2 = new Key($resource);

$store->save($key1);
$store->waitAndSave($key2);
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Lock/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"psr/log": "~1.0"
},
"require-dev": {
"doctrine/dbal": "~2.4",
"predis/predis": "~1.0"
},
"autoload": {
Expand Down
3 changes: 3 additions & 0 deletions src/Symfony/Component/Lock/phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
<ini name="error_reporting" value="-1" />
<env name="REDIS_HOST" value="localhost" />
<env name="MEMCACHED_HOST" value="localhost" />
<env name="MYSQL_HOST" value="127.0.0.1" />
<env name="MYSQL_USERNAME" value="root" />
<env name="MYSQL_PASSWORD" value="" />
</php>

<testsuites>
Expand Down