-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Lock] Add MysqlStore
#45982
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
base: 7.4
Are you sure you want to change the base?
[Lock] Add MysqlStore
#45982
Changes from 7 commits
6a18831
f849ecb
2f26e01
6954fc6
4730f91
d28db69
2def8dd
cda417a
827545a
9a32d39
e2f0faa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,8 @@ | ||
CHANGELOG | ||
========= | ||
6.2 | ||
--- | ||
* Add `MysqlStore` based on MySQL `GET_LOCK()` functionality | ||
|
||
6.0 | ||
--- | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
<?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\LockAcquiringException; | ||
use Symfony\Component\Lock\Exception\LockConflictedException; | ||
use Symfony\Component\Lock\Key; | ||
use Symfony\Component\Lock\PersistingStoreInterface; | ||
|
||
/** | ||
* @author rtek | ||
* @author Jérôme TAMARELLE <jerome@tamarelle.net> | ||
*/ | ||
class MysqlStore implements PersistingStoreInterface | ||
{ | ||
private \PDO $conn; | ||
|
||
private string $dsn; | ||
|
||
private array $options; | ||
|
||
private int $connectionId; | ||
|
||
/** @var bool[] */ | ||
private static array $locksAcquired = []; | ||
|
||
public function __construct(\PDO|string $connOrDsn, array $options = []) | ||
{ | ||
if ($connOrDsn instanceof \PDO) { | ||
$this->conn = $connOrDsn; | ||
$this->assertMysqlDriver(); | ||
if (\PDO::ERRMODE_EXCEPTION !== $this->conn->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__)); | ||
} | ||
} else { | ||
$this->dsn = $connOrDsn; | ||
} | ||
|
||
$this->options = $options; | ||
} | ||
|
||
public function save(Key $key): void | ||
{ | ||
$id = $this->getLockId($key); | ||
|
||
if (self::$locksAcquired[$id] ?? false) { | ||
return; | ||
} | ||
|
||
$stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you elaborate on this requirement? Im concerned that using the
I moved the state into the store since the tests assert that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
$stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); | ||
$stmt->execute(); | ||
$result = $stmt->fetchColumn(); | ||
|
||
// lock acquired | ||
if (1 === $result) { | ||
self::$locksAcquired[$id] = true; | ||
|
||
return; | ||
} | ||
|
||
if (0 === $result) { | ||
throw new LockConflictedException('Lock already acquired by other connection.'); | ||
} | ||
|
||
if (-1 === $result) { | ||
throw new LockConflictedException('Lock already acquired by this connection.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a Lock already acquired by this connection should not throw a LockConflictedException (btw, this is what your |
||
} | ||
|
||
throw new LockAcquiringException('Failed to acquire lock due to mysql error.'); | ||
} | ||
|
||
public function putOffExpiration(Key $key, float $ttl): void | ||
{ | ||
// noop - GET_LOCK() does not have a ttl | ||
} | ||
|
||
public function delete(Key $key): void | ||
{ | ||
$stmt = $this->conn->prepare('DO RELEASE_LOCK(:name)'); | ||
$stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); | ||
$stmt->execute(); | ||
|
||
unset(self::$locksAcquired[$this->getLockId($key)]); | ||
} | ||
|
||
public function exists(Key $key): bool | ||
{ | ||
return self::$locksAcquired[$this->getLockId($key)] ?? false; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's wrong, the implementation must assert that the lock is still present in the backend. (Ie the database restart, the network is down, ...) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This wont be possible since GET_LOCK is bound to the connection so its always released when the connection is terminated. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well.. This means, that each time the server restarts, the application might retrieve the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this should do it. I cant add this test case to the since it requires rebooting mysql but I did run it manually to confirm proper handling even if the connection id is the same
|
||
} | ||
|
||
private function getConnection(): \PDO | ||
{ | ||
if (!isset($this->conn)) { | ||
$this->conn = new \PDO( | ||
$this->dsn, | ||
$this->options['db_username'] ?? null, | ||
$this->options['db_password'] ?? null, | ||
$this->options['db_connection_options'] ?? null | ||
); | ||
$this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); | ||
$this->assertMysqlDriver(); | ||
} | ||
|
||
return $this->conn; | ||
} | ||
|
||
private function assertMysqlDriver(): void | ||
{ | ||
if ('mysql' !== $driver = $this->conn->getAttribute(\PDO::ATTR_DRIVER_NAME)) { | ||
throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, $driver)); | ||
} | ||
} | ||
|
||
private function getLockId(Key $key): string | ||
{ | ||
if (!isset($this->connectionId)) { | ||
$this->connectionId = $this->getConnection()->query('SELECT CONNECTION_ID()')->fetchColumn(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line triggers There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replaced the MYSQL_HOST with MYSQL_DSN envar so you can test using a socket dsn. I think that's what your error is related to. |
||
} | ||
|
||
return $this->connectionId.'_'.spl_object_id($key); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The above code should work
|
||
} | ||
|
||
private static function getLockName(Key $key): string | ||
{ | ||
// mysql limits lock name length to 64 chars | ||
$name = (string) $key; | ||
|
||
return \strlen($name) > 64 ? hash('xxh128', $name) : $name; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hashing only longer names will mean that there is 2 resources that share the same MySQL lock name |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
<?php | ||
|
||
namespace Symfony\Component\Lock\Tests\Store; | ||
|
||
use Symfony\Component\Lock\Exception\InvalidArgumentException; | ||
use Symfony\Component\Lock\Exception\LockConflictedException; | ||
use Symfony\Component\Lock\Key; | ||
use Symfony\Component\Lock\PersistingStoreInterface; | ||
use Symfony\Component\Lock\Store\MysqlStore; | ||
|
||
class MysqlStoreTest extends AbstractStoreTest | ||
{ | ||
protected function getEnv(): array | ||
{ | ||
if (!$host = getenv('MYSQL_HOST')) { | ||
$this->markTestSkipped('Missing MYSQL_HOST env variable'); | ||
} | ||
|
||
if (!$user = getenv('MYSQL_USERNAME')) { | ||
$this->markTestSkipped('Missing MYSQL_USERNAME env variable'); | ||
} | ||
|
||
if (!$pass = getenv('MYSQL_PASSWORD')) { | ||
$this->markTestSkipped('Missing MYSQL_PASSWORD env variable'); | ||
} | ||
|
||
return [$host, $user, $pass]; | ||
} | ||
|
||
protected function getPdo(): \PDO | ||
{ | ||
[$host, $user, $pass] = $this->getEnv(); | ||
|
||
return new \PDO('mysql:host='.$host, $user, $pass); | ||
} | ||
|
||
protected function getStore(): PersistingStoreInterface | ||
{ | ||
return new MysqlStore($this->getPdo()); | ||
} | ||
|
||
public function testDriverRequirement() | ||
{ | ||
$this->expectException(InvalidArgumentException::class); | ||
new MysqlStore(new \PDO('sqlite::memory:')); | ||
} | ||
|
||
public function testExceptionModeRequirement() | ||
{ | ||
$this->expectException(InvalidArgumentException::class); | ||
$pdo = $this->getPdo(); | ||
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT); | ||
new MysqlStore($pdo); | ||
} | ||
|
||
public function testOtherConflictException() | ||
{ | ||
$storeA = $this->getStore(); | ||
$storeA->save(new Key('foo')); | ||
|
||
$storeB = $this->getStore(); | ||
|
||
try { | ||
$storeB->save(new Key('foo')); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test is incorrect. You are testing both:
You don't know why the For the record the below code is OK:
Because stores are stateless, and you are allowed to use 2 instances of store to manipulate the same lock. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
makes sense I see where you're going now |
||
$this->fail('Expected exception: '.LockConflictedException::class); | ||
} catch (LockConflictedException $e) { | ||
$this->assertStringContainsString('acquired by other', $e->getMessage()); | ||
} | ||
} | ||
|
||
public function testDsnConstructor() | ||
{ | ||
$this->expectNotToPerformAssertions(); | ||
|
||
[$host, $user, $pass] = $this->getEnv(); | ||
$store = new MysqlStore("mysql:$host", ['db_username' => $user, 'db_password' => $pass]); | ||
$store->save(new Key('foo')); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe rename it to
MysqlLocksStore
to indicate Mysql user locks are used (not to be confused with data storage in Pdo store