From 6a18831ff0ecaeae3768a5b46b80b4e50753e824 Mon Sep 17 00:00:00 2001 From: rtek Date: Sat, 9 Apr 2022 18:40:45 -0400 Subject: [PATCH 01/11] Add MysqlStore --- .github/workflows/integration-tests.yml | 6 ++ .../Lock/Store/DoctrineDbalStore.php | 1 + .../Component/Lock/Store/MysqlStore.php | 97 +++++++++++++++++++ .../Lock/Tests/Store/MysqlStoreTest.php | 76 +++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 src/Symfony/Component/Lock/Store/MysqlStore.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index bf62788b7a81e..838f46995f6fc 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -161,6 +161,9 @@ jobs: ./phpunit install echo "::endgroup::" + - name: Start MySQL + run: sudo systemctl start mysql.service + - name: Run tests run: ./phpunit --group integration -v env: @@ -174,6 +177,9 @@ jobs: MESSENGER_SQS_FIFO_QUEUE_DSN: "sqs://localhost:9494/messages.fifo?sslmode=disable&poll_timeout=0.01" KAFKA_BROKER: 127.0.0.1:9092 POSTGRES_HOST: localhost + MYSQL_HOST: localhost # ubuntu-20.04 mysql defaults + MYSQL_USERNAME: root + MYSQL_PASSWORD: root #- name: Run HTTP push tests # if: matrix.php == '8.1' diff --git a/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php b/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php index 25b820d71d0a4..c29dd36f76cd6 100644 --- a/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php +++ b/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php @@ -223,6 +223,7 @@ private function prune(): void private function getCurrentTimestampStatement(): string { $platform = $this->conn->getDatabasePlatform(); + return match (true) { $platform instanceof \Doctrine\DBAL\Platforms\MySQLPlatform, $platform instanceof \Doctrine\DBAL\Platforms\MySQL57Platform => 'UNIX_TIMESTAMP()', diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php new file mode 100644 index 0000000000000..ffa6150a140ff --- /dev/null +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -0,0 +1,97 @@ + + */ +class MysqlStore implements PersistingStoreInterface +{ + private \PDO $conn; + + public function __construct(\PDO $conn) + { + if ('mysql' !== $driver = $conn->getAttribute(\PDO::ATTR_DRIVER_NAME)) { + throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, $driver)); + } + + if (\PDO::ERRMODE_EXCEPTION !== $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__)); + } + + $this->conn = $conn; + } + + public function save(Key $key): void + { + if ($key->hasState(__CLASS__)) { + return; + } + + // todo ? check that mysql > 5.7.3 + + // mysql limits lock name length to 64 chars + $name = (string) $key; + $name = \strlen($name) > 64 ? hash('sha256', $name) : $name; + + $stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); + $stmt->bindValue(':name', $name, \PDO::PARAM_STR); + $stmt->execute(); + $result = $stmt->fetchColumn(); + + // lock acquired + if (1 === $result) { + $key->setState(__CLASS__, $name); + + 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.'); + } + + 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 + { + if (!$key->hasState(__CLASS__)) { + return; + } + + $stmt = $this->conn->prepare('DO RELEASE_LOCK(:name)'); + $stmt->bindValue(':name', $key->getState(__CLASS__), \PDO::PARAM_STR); + $stmt->execute(); + + $key->removeState(__CLASS__); + } + + public function exists(Key $key): bool + { + if (!$key->hasState(__CLASS__)) { + return false; + } + + $stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), 1, 0)'); + $stmt->bindValue(':name', $key->getState(__CLASS__), \PDO::PARAM_STR); + $stmt->execute(); + + return 1 === $stmt->fetchColumn(); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php new file mode 100644 index 0000000000000..e270eceeeff42 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php @@ -0,0 +1,76 @@ +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 new \PDO('mysql:host='.$host, $user, $pass); + } + + protected function getStore(): PersistingStoreInterface + { + return new MysqlStore($this->getPdo()); + } + + public function testDriverRequirement(): void + { + $this->expectException(InvalidArgumentException::class); + new MysqlStore(new \PDO('sqlite::memory:')); + } + + public function testExceptionModeRequirement(): void + { + $this->expectException(InvalidArgumentException::class); + $pdo = $this->getPdo(); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT); + new MysqlStore($pdo); + } + + public function testSelfConflictException(): void + { + $store = $this->getStore(); + $store->save(new Key('foo')); + + try { + $store->save(new Key('foo')); + $this->fail('Expected exception: '.LockConflictedException::class); + } catch (LockConflictedException $e) { + $this->assertStringContainsString('acquired by this', $e->getMessage()); + } + } + + public function testOtherConflictException(): void + { + $storeA = $this->getStore(); + $storeA->save(new Key('foo')); + + $storeB = $this->getStore(); + + try { + $storeB->save(new Key('foo')); + $this->fail('Expected exception: '.LockConflictedException::class); + } catch (LockConflictedException $e) { + $this->assertStringContainsString('acquired by other', $e->getMessage()); + } + } +} From f849ecb62d4806c02dc35285bce3ac9e53173e0b Mon Sep 17 00:00:00 2001 From: rtek Date: Sat, 9 Apr 2022 19:50:15 -0400 Subject: [PATCH 02/11] Fix changelog --- src/Symfony/Component/Lock/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 45d08d29f36ba..78f4da65bdc20 100644 --- a/src/Symfony/Component/Lock/CHANGELOG.md +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -1,5 +1,8 @@ CHANGELOG ========= +6.1 +--- +* Add `MysqlStore` based on MySQL `GET_LOCK()` functionality 6.0 --- From 2f26e01659dbe215a455558be1503dd71868db79 Mon Sep 17 00:00:00 2001 From: rtek Date: Sat, 9 Apr 2022 20:04:34 -0400 Subject: [PATCH 03/11] Fix fabbot --- src/Symfony/Component/Lock/Store/MysqlStore.php | 11 ++++++++++- .../Component/Lock/Tests/Store/MysqlStoreTest.php | 8 ++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index ffa6150a140ff..f37ed536f344f 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -1,5 +1,14 @@ + * + * 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; @@ -61,7 +70,7 @@ public function save(Key $key): void throw new LockConflictedException('Lock already acquired by this connection.'); } - throw new LockAcquiringException('Failed to acquire lock due to mysql error'); + throw new LockAcquiringException('Failed to acquire lock due to mysql error.'); } public function putOffExpiration(Key $key, float $ttl): void diff --git a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php index e270eceeeff42..efe310926779b 100644 --- a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php @@ -32,13 +32,13 @@ protected function getStore(): PersistingStoreInterface return new MysqlStore($this->getPdo()); } - public function testDriverRequirement(): void + public function testDriverRequirement() { $this->expectException(InvalidArgumentException::class); new MysqlStore(new \PDO('sqlite::memory:')); } - public function testExceptionModeRequirement(): void + public function testExceptionModeRequirement() { $this->expectException(InvalidArgumentException::class); $pdo = $this->getPdo(); @@ -46,7 +46,7 @@ public function testExceptionModeRequirement(): void new MysqlStore($pdo); } - public function testSelfConflictException(): void + public function testSelfConflictException() { $store = $this->getStore(); $store->save(new Key('foo')); @@ -59,7 +59,7 @@ public function testSelfConflictException(): void } } - public function testOtherConflictException(): void + public function testOtherConflictException() { $storeA = $this->getStore(); $storeA->save(new Key('foo')); From 6954fc646b659ca29e4b6ee25bc5e620436d76a0 Mon Sep 17 00:00:00 2001 From: rtek Date: Sun, 10 Apr 2022 20:50:58 -0400 Subject: [PATCH 04/11] Add dsn support --- src/Symfony/Component/Lock/CHANGELOG.md | 2 +- .../Component/Lock/Store/MysqlStore.php | 51 ++++++++++++++----- .../Lock/Tests/Store/MysqlStoreTest.php | 17 ++++++- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 78f4da65bdc20..a5c2c921f106d 100644 --- a/src/Symfony/Component/Lock/CHANGELOG.md +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -1,6 +1,6 @@ CHANGELOG ========= -6.1 +6.2 --- * Add `MysqlStore` based on MySQL `GET_LOCK()` functionality diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index f37ed536f344f..589a5097bc32a 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -23,19 +23,25 @@ */ class MysqlStore implements PersistingStoreInterface { - private \PDO $conn; + private ?\PDO $conn; - public function __construct(\PDO $conn) - { - if ('mysql' !== $driver = $conn->getAttribute(\PDO::ATTR_DRIVER_NAME)) { - throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, $driver)); - } + private ?string $dsn; + + private array $options; - if (\PDO::ERRMODE_EXCEPTION !== $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__)); + 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->conn = $conn; + $this->options = $options; } public function save(Key $key): void @@ -44,11 +50,9 @@ public function save(Key $key): void return; } - // todo ? check that mysql > 5.7.3 - // mysql limits lock name length to 64 chars $name = (string) $key; - $name = \strlen($name) > 64 ? hash('sha256', $name) : $name; + $name = \strlen($name) > 64 ? hash('xxh128', $name) : $name; $stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); $stmt->bindValue(':name', $name, \PDO::PARAM_STR); @@ -103,4 +107,27 @@ public function exists(Key $key): bool return 1 === $stmt->fetchColumn(); } + + private function getConnection(): \PDO + { + if (!$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)); + } + } } diff --git a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php index efe310926779b..76b09e72e6815 100644 --- a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php @@ -10,7 +10,7 @@ class MysqlStoreTest extends AbstractStoreTest { - protected function getPdo(): \PDO + protected function getEnv(): array { if (!$host = getenv('MYSQL_HOST')) { $this->markTestSkipped('Missing MYSQL_HOST env variable'); @@ -24,6 +24,13 @@ protected function getPdo(): \PDO $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); } @@ -73,4 +80,12 @@ public function testOtherConflictException() $this->assertStringContainsString('acquired by other', $e->getMessage()); } } + + public function testDsnConstructor() + { + $this->expectNotToPerformAssertions(); + + [$host, $user, $pass] = $this->getEnv(); + new MysqlStore("mysql:$host", ['db_username' => $user, 'db_password' => $pass]); + } } From 4730f912f54a0f46b04d54f542d646344188759d Mon Sep 17 00:00:00 2001 From: rtek Date: Sun, 10 Apr 2022 21:57:40 -0400 Subject: [PATCH 05/11] Move state into store from key --- .../Component/Lock/Store/MysqlStore.php | 46 ++++++++++--------- .../Lock/Tests/Store/MysqlStoreTest.php | 13 ------ 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index 589a5097bc32a..21decec33449b 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -29,6 +29,9 @@ class MysqlStore implements PersistingStoreInterface private array $options; + /** @var bool[] */ + private static array $locksAcquired = []; + public function __construct(\PDO|string $connOrDsn, array $options = []) { if ($connOrDsn instanceof \PDO) { @@ -46,22 +49,20 @@ public function __construct(\PDO|string $connOrDsn, array $options = []) public function save(Key $key): void { - if ($key->hasState(__CLASS__)) { + $id = $this->getLockId($key); + + if (self::$locksAcquired[$id] ?? false) { return; } - // mysql limits lock name length to 64 chars - $name = (string) $key; - $name = \strlen($name) > 64 ? hash('xxh128', $name) : $name; - $stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); - $stmt->bindValue(':name', $name, \PDO::PARAM_STR); + $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); $stmt->execute(); $result = $stmt->fetchColumn(); // lock acquired if (1 === $result) { - $key->setState(__CLASS__, $name); + self::$locksAcquired[$id] = true; return; } @@ -84,28 +85,16 @@ public function putOffExpiration(Key $key, float $ttl): void public function delete(Key $key): void { - if (!$key->hasState(__CLASS__)) { - return; - } - $stmt = $this->conn->prepare('DO RELEASE_LOCK(:name)'); - $stmt->bindValue(':name', $key->getState(__CLASS__), \PDO::PARAM_STR); + $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); $stmt->execute(); - $key->removeState(__CLASS__); + unset(self::$locksAcquired[$this->getLockId($key)]); } public function exists(Key $key): bool { - if (!$key->hasState(__CLASS__)) { - return false; - } - - $stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), 1, 0)'); - $stmt->bindValue(':name', $key->getState(__CLASS__), \PDO::PARAM_STR); - $stmt->execute(); - - return 1 === $stmt->fetchColumn(); + return self::$locksAcquired[$this->getLockId($key)] ?? false; } private function getConnection(): \PDO @@ -130,4 +119,17 @@ private function assertMysqlDriver(): void throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, $driver)); } } + + private function getLockId(Key $key): string + { + return spl_object_id($this->getConnection()).'_'.spl_object_id($key); + } + + 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; + } } diff --git a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php index 76b09e72e6815..c9b3c00a8ba46 100644 --- a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php @@ -53,19 +53,6 @@ public function testExceptionModeRequirement() new MysqlStore($pdo); } - public function testSelfConflictException() - { - $store = $this->getStore(); - $store->save(new Key('foo')); - - try { - $store->save(new Key('foo')); - $this->fail('Expected exception: '.LockConflictedException::class); - } catch (LockConflictedException $e) { - $this->assertStringContainsString('acquired by this', $e->getMessage()); - } - } - public function testOtherConflictException() { $storeA = $this->getStore(); From d28db69bee3fbc2716da3c09aae2c8cb5d7d3b2a Mon Sep 17 00:00:00 2001 From: rtek Date: Sun, 10 Apr 2022 22:19:22 -0400 Subject: [PATCH 06/11] Use the connection id for lock state --- src/Symfony/Component/Lock/Store/MysqlStore.php | 10 ++++++++-- .../Component/Lock/Tests/Store/MysqlStoreTest.php | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index 21decec33449b..b37a400724af6 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -23,12 +23,14 @@ */ class MysqlStore implements PersistingStoreInterface { - private ?\PDO $conn; + private ?\PDO $conn = null; private ?string $dsn; private array $options; + private ?int $connectionId = null; + /** @var bool[] */ private static array $locksAcquired = []; @@ -122,7 +124,11 @@ private function assertMysqlDriver(): void private function getLockId(Key $key): string { - return spl_object_id($this->getConnection()).'_'.spl_object_id($key); + if (!$this->connectionId) { + $this->connectionId = $this->getConnection()->query('SELECT CONNECTION_ID()')->fetchColumn(); + } + + return $this->connectionId.'_'.spl_object_id($key); } private static function getLockName(Key $key): string diff --git a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php index c9b3c00a8ba46..8b5b708f9faab 100644 --- a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php @@ -73,6 +73,7 @@ public function testDsnConstructor() $this->expectNotToPerformAssertions(); [$host, $user, $pass] = $this->getEnv(); - new MysqlStore("mysql:$host", ['db_username' => $user, 'db_password' => $pass]); + $store = new MysqlStore("mysql:$host", ['db_username' => $user, 'db_password' => $pass]); + $store->save(new Key('foo')); } } From 2def8dd98563f54fe4f22232a7d9f4cf12d82c37 Mon Sep 17 00:00:00 2001 From: rtek Date: Sun, 10 Apr 2022 22:30:48 -0400 Subject: [PATCH 07/11] Fix lazy property semantics --- src/Symfony/Component/Lock/Store/MysqlStore.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index b37a400724af6..bab39d791f573 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -23,13 +23,13 @@ */ class MysqlStore implements PersistingStoreInterface { - private ?\PDO $conn = null; + private \PDO $conn; - private ?string $dsn; + private string $dsn; private array $options; - private ?int $connectionId = null; + private int $connectionId; /** @var bool[] */ private static array $locksAcquired = []; @@ -101,7 +101,7 @@ public function exists(Key $key): bool private function getConnection(): \PDO { - if (!$this->conn) { + if (!isset($this->conn)) { $this->conn = new \PDO( $this->dsn, $this->options['db_username'] ?? null, @@ -124,7 +124,7 @@ private function assertMysqlDriver(): void private function getLockId(Key $key): string { - if (!$this->connectionId) { + if (!isset($this->connectionId)) { $this->connectionId = $this->getConnection()->query('SELECT CONNECTION_ID()')->fetchColumn(); } From cda417a53a4c9099a6025ccb43587b3df16d749c Mon Sep 17 00:00:00 2001 From: rtek Date: Mon, 11 Apr 2022 11:39:10 -0400 Subject: [PATCH 08/11] Move lock state into key from store --- .../Component/Lock/Store/MysqlStore.php | 21 +++++----- .../Lock/Tests/Store/MysqlStoreTest.php | 39 +++++++++++++++++-- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index bab39d791f573..cc9fa54f50cc6 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -31,9 +31,6 @@ class MysqlStore implements PersistingStoreInterface private int $connectionId; - /** @var bool[] */ - private static array $locksAcquired = []; - public function __construct(\PDO|string $connOrDsn, array $options = []) { if ($connOrDsn instanceof \PDO) { @@ -51,20 +48,20 @@ public function __construct(\PDO|string $connOrDsn, array $options = []) public function save(Key $key): void { - $id = $this->getLockId($key); - - if (self::$locksAcquired[$id] ?? false) { + $stateKey = $this->getStateKey($key); + if ($key->hasState($stateKey)) { return; } + $name = self::getLockName($key); $stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); - $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); + $stmt->bindValue(':name', $name, \PDO::PARAM_STR); $stmt->execute(); $result = $stmt->fetchColumn(); // lock acquired if (1 === $result) { - self::$locksAcquired[$id] = true; + $key->setState($stateKey, $name); return; } @@ -91,12 +88,12 @@ public function delete(Key $key): void $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); $stmt->execute(); - unset(self::$locksAcquired[$this->getLockId($key)]); + $key->removeState($this->getStateKey($key)); } public function exists(Key $key): bool { - return self::$locksAcquired[$this->getLockId($key)] ?? false; + return $key->hasState($this->getStateKey($key)); } private function getConnection(): \PDO @@ -122,13 +119,13 @@ private function assertMysqlDriver(): void } } - private function getLockId(Key $key): string + private function getStateKey(Key $key): string { if (!isset($this->connectionId)) { $this->connectionId = $this->getConnection()->query('SELECT CONNECTION_ID()')->fetchColumn(); } - return $this->connectionId.'_'.spl_object_id($key); + return __CLASS__.'_'.$this->connectionId; } private static function getLockName(Key $key): string diff --git a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php index 8b5b708f9faab..39d6b69c3f3d4 100644 --- a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php @@ -53,21 +53,52 @@ public function testExceptionModeRequirement() new MysqlStore($pdo); } - public function testOtherConflictException() + public function testOtherConnConflictException() { $storeA = $this->getStore(); - $storeA->save(new Key('foo')); - $storeB = $this->getStore(); + $key = new Key('foo'); + $storeA->save($key); + + $this->assertFalse($storeB->exists($key)); + try { - $storeB->save(new Key('foo')); + $storeB->save($key); $this->fail('Expected exception: '.LockConflictedException::class); } catch (LockConflictedException $e) { $this->assertStringContainsString('acquired by other', $e->getMessage()); } } + public function testExistsOnKeyClone() + { + $store = $this->getStore(); + + $key = new Key('foo'); + $store->save($key); + + $this->assertTrue($store->exists($key)); + $this->assertTrue($store->exists(clone $key)); + } + + public function testStoresAreStateless() + { + $pdo = $this->getPdo(); + + $storeA = new MysqlStore($pdo); + $storeB = new MysqlStore($pdo); + $key = new Key('foo'); + + $storeA->save($key); + $this->assertTrue($storeA->exists($key)); + $this->assertTrue($storeB->exists($key)); + + $storeB->delete($key); + $this->assertFalse($storeB->exists($key)); + $this->assertFalse($storeA->exists($key)); + } + public function testDsnConstructor() { $this->expectNotToPerformAssertions(); From 827545ac520a66c03eabca9754f6894998d8ffef Mon Sep 17 00:00:00 2001 From: rtek Date: Mon, 11 Apr 2022 12:55:13 -0400 Subject: [PATCH 09/11] Handle mysql restarts --- .../Component/Lock/Store/MysqlStore.php | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index cc9fa54f50cc6..aa071ae8c64a7 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -48,20 +48,19 @@ public function __construct(\PDO|string $connOrDsn, array $options = []) public function save(Key $key): void { - $stateKey = $this->getStateKey($key); - if ($key->hasState($stateKey)) { + if ($this->exists($key)) { return; } $name = self::getLockName($key); - $stmt = $this->conn->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); + $stmt = $this->getConnection()->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); $stmt->bindValue(':name', $name, \PDO::PARAM_STR); $stmt->execute(); $result = $stmt->fetchColumn(); // lock acquired if (1 === $result) { - $key->setState($stateKey, $name); + $key->setState($this->getStateKey($key), $name); return; } @@ -84,7 +83,7 @@ public function putOffExpiration(Key $key, float $ttl): void public function delete(Key $key): void { - $stmt = $this->conn->prepare('DO RELEASE_LOCK(:name)'); + $stmt = $this->getConnection()->prepare('DO RELEASE_LOCK(:name)'); $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); $stmt->execute(); @@ -93,7 +92,23 @@ public function delete(Key $key): void public function exists(Key $key): bool { - return $key->hasState($this->getStateKey($key)); + $stateKey = $this->getStateKey($key); + if (!$key->hasState($stateKey)) { + return false; + } + + $stmt = $this->getConnection()->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), 1, 0)'); + $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); + $stmt->execute(); + $result = $stmt->fetchColumn(); + + if (1 === $result) { + return true; + } + + $key->removeState($stateKey); + + return false; } private function getConnection(): \PDO From 9a32d39f53af9fe8e75a5079bb65fe1f28217de5 Mon Sep 17 00:00:00 2001 From: rtek Date: Mon, 11 Apr 2022 13:00:54 -0400 Subject: [PATCH 10/11] Allow tests to use a dsn instead of host --- .github/workflows/integration-tests.yml | 2 +- .../Component/Lock/Tests/Store/MysqlStoreTest.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 838f46995f6fc..be112214adbcd 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -177,7 +177,7 @@ jobs: MESSENGER_SQS_FIFO_QUEUE_DSN: "sqs://localhost:9494/messages.fifo?sslmode=disable&poll_timeout=0.01" KAFKA_BROKER: 127.0.0.1:9092 POSTGRES_HOST: localhost - MYSQL_HOST: localhost # ubuntu-20.04 mysql defaults + MYSQL_DSN: mysql:host=localhost # ubuntu-20.04 mysql defaults MYSQL_USERNAME: root MYSQL_PASSWORD: root diff --git a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php index 39d6b69c3f3d4..d708e0a896624 100644 --- a/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/MysqlStoreTest.php @@ -12,8 +12,8 @@ class MysqlStoreTest extends AbstractStoreTest { protected function getEnv(): array { - if (!$host = getenv('MYSQL_HOST')) { - $this->markTestSkipped('Missing MYSQL_HOST env variable'); + if (!$dsn = getenv('MYSQL_DSN')) { + $this->markTestSkipped('Missing MYSQL_DSN env variable'); } if (!$user = getenv('MYSQL_USERNAME')) { @@ -24,14 +24,14 @@ protected function getEnv(): array $this->markTestSkipped('Missing MYSQL_PASSWORD env variable'); } - return [$host, $user, $pass]; + return [$dsn, $user, $pass]; } protected function getPdo(): \PDO { - [$host, $user, $pass] = $this->getEnv(); + [$dsn, $user, $pass] = $this->getEnv(); - return new \PDO('mysql:host='.$host, $user, $pass); + return new \PDO($dsn, $user, $pass); } protected function getStore(): PersistingStoreInterface From e2f0faa3ebcb5d0e6bbf575ccd1b6e994ac10a6a Mon Sep 17 00:00:00 2001 From: rtek Date: Thu, 21 Apr 2022 20:49:18 -0400 Subject: [PATCH 11/11] Address feedback --- .../Component/Lock/Store/MysqlStore.php | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/MysqlStore.php b/src/Symfony/Component/Lock/Store/MysqlStore.php index aa071ae8c64a7..cf914bc60e1d0 100644 --- a/src/Symfony/Component/Lock/Store/MysqlStore.php +++ b/src/Symfony/Component/Lock/Store/MysqlStore.php @@ -31,6 +31,12 @@ class MysqlStore implements PersistingStoreInterface private int $connectionId; + private \PDOStatement $saveStmt; + + private \PDOStatement $existsStmt; + + private \PDOStatement $deleteStmt; + public function __construct(\PDO|string $connOrDsn, array $options = []) { if ($connOrDsn instanceof \PDO) { @@ -52,10 +58,11 @@ public function save(Key $key): void return; } + $stmt = $this->saveStmt ?? + $this->saveStmt = $this->getConnection()->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); + $name = self::getLockName($key); - $stmt = $this->getConnection()->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), -1, GET_LOCK(:name, 0))'); - $stmt->bindValue(':name', $name, \PDO::PARAM_STR); - $stmt->execute(); + $stmt->execute(['name' => $name]); $result = $stmt->fetchColumn(); // lock acquired @@ -83,9 +90,10 @@ public function putOffExpiration(Key $key, float $ttl): void public function delete(Key $key): void { - $stmt = $this->getConnection()->prepare('DO RELEASE_LOCK(:name)'); - $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); - $stmt->execute(); + $stmt = $this->deleteStmt ?? + $this->deleteStmt = $this->getConnection()->prepare('DO RELEASE_LOCK(:name)'); + + $stmt->execute(['name' => self::getLockName($key)]); $key->removeState($this->getStateKey($key)); } @@ -97,18 +105,19 @@ public function exists(Key $key): bool return false; } - $stmt = $this->getConnection()->prepare('SELECT IF(IS_USED_LOCK(:name) = CONNECTION_ID(), 1, 0)'); - $stmt->bindValue(':name', self::getLockName($key), \PDO::PARAM_STR); - $stmt->execute(); + $stmt = $this->existsStmt ?? + $this->existsStmt = $this->getConnection()->prepare('SELECT IS_USED_LOCK(:name) = CONNECTION_ID()'); + + $stmt->execute(['name' => self::getLockName($key)]); $result = $stmt->fetchColumn(); - if (1 === $result) { - return true; - } + if (1 !== $result) { + $key->removeState($stateKey); - $key->removeState($stateKey); + return false; + } - return false; + return true; } private function getConnection(): \PDO