From 32a0f506e6324ed2ce33dc4fd3dcb44afb6b02de Mon Sep 17 00:00:00 2001 From: Christopher Davis Date: Wed, 23 Feb 2022 08:58:15 -0600 Subject: [PATCH] [Lock] Release Locks from Internal Store on Postgres waitAndSave* --- .../Store/DoctrineDbalPostgreSqlStore.php | 28 ++++-- .../Component/Lock/Store/PostgreSqlStore.php | 29 ++++-- .../Store/DoctrineDbalPostgreSqlStoreTest.php | 88 +++++++++++++++++- .../Lock/Tests/Store/PostgreSqlStoreTest.php | 91 ++++++++++++++++++- 4 files changed, 215 insertions(+), 21 deletions(-) diff --git a/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php b/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php index efff1948a88b1..18f9352a511b1 100644 --- a/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php +++ b/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php @@ -173,10 +173,18 @@ public function waitAndSave(Key $key) // Internal store does not allow blocking mode, because there is no way to acquire one in a single process $this->getInternalStore()->save($key); + $lockAcquired = false; $sql = 'SELECT pg_advisory_lock(:key)'; - $this->conn->executeStatement($sql, [ - 'key' => $this->getHashedKey($key), - ]); + try { + $this->conn->executeStatement($sql, [ + 'key' => $this->getHashedKey($key), + ]); + $lockAcquired = true; + } finally { + if (!$lockAcquired) { + $this->getInternalStore()->delete($key); + } + } // release lock in case of promotion $this->unlockShared($key); @@ -188,10 +196,18 @@ public function waitAndSaveRead(Key $key) // Internal store does not allow blocking mode, because there is no way to acquire one in a single process $this->getInternalStore()->saveRead($key); + $lockAcquired = false; $sql = 'SELECT pg_advisory_lock_shared(:key)'; - $this->conn->executeStatement($sql, [ - 'key' => $this->getHashedKey($key), - ]); + try { + $this->conn->executeStatement($sql, [ + 'key' => $this->getHashedKey($key), + ]); + $lockAcquired = true; + } finally { + if (!$lockAcquired) { + $this->getInternalStore()->delete($key); + } + } // release lock in case of demotion $this->unlock($key); diff --git a/src/Symfony/Component/Lock/Store/PostgreSqlStore.php b/src/Symfony/Component/Lock/Store/PostgreSqlStore.php index b385f28347b89..6c78386da1cff 100644 --- a/src/Symfony/Component/Lock/Store/PostgreSqlStore.php +++ b/src/Symfony/Component/Lock/Store/PostgreSqlStore.php @@ -235,11 +235,18 @@ public function waitAndSave(Key $key) // Internal store does not allow blocking mode, because there is no way to acquire one in a single process $this->getInternalStore()->save($key); + $lockAcquired = false; $sql = 'SELECT pg_advisory_lock(:key)'; - $stmt = $this->getConnection()->prepare($sql); - - $stmt->bindValue(':key', $this->getHashedKey($key)); - $stmt->execute(); + try { + $stmt = $this->getConnection()->prepare($sql); + $stmt->bindValue(':key', $this->getHashedKey($key)); + $stmt->execute(); + $lockAcquired = true; + } finally { + if (!$lockAcquired) { + $this->getInternalStore()->delete($key); + } + } // release lock in case of promotion $this->unlockShared($key); @@ -257,11 +264,19 @@ public function waitAndSaveRead(Key $key) // Internal store does not allow blocking mode, because there is no way to acquire one in a single process $this->getInternalStore()->saveRead($key); + $lockAcquired = false; $sql = 'SELECT pg_advisory_lock_shared(:key)'; - $stmt = $this->getConnection()->prepare($sql); - $stmt->bindValue(':key', $this->getHashedKey($key)); - $stmt->execute(); + try { + $stmt = $this->getConnection()->prepare($sql); + $stmt->bindValue(':key', $this->getHashedKey($key)); + $stmt->execute(); + $lockAcquired = true; + } finally { + if (!$lockAcquired) { + $this->getInternalStore()->delete($key); + } + } // release lock in case of demotion $this->unlock($key); diff --git a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php index 30a5d0a1f503b..eaf9f7b7b5cb9 100644 --- a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php @@ -11,7 +11,9 @@ namespace Symfony\Component\Lock\Tests\Store; +use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception as DBALException; use Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Exception\LockConflictedException; use Symfony\Component\Lock\Key; @@ -29,15 +31,21 @@ class DoctrineDbalPostgreSqlStoreTest extends AbstractStoreTest use BlockingStoreTestTrait; use SharedLockStoreTestTrait; + public function createPostgreSqlConnection(): Connection + { + if (!getenv('POSTGRES_HOST')) { + $this->markTestSkipped('Missing POSTGRES_HOST env variable'); + } + + return DriverManager::getConnection(['url' => 'pgsql://postgres:password@'.getenv('POSTGRES_HOST')]); + } + /** * {@inheritdoc} */ public function getStore(): PersistingStoreInterface { - if (!getenv('POSTGRES_HOST')) { - $this->markTestSkipped('Missing POSTGRES_HOST env variable'); - } - $conn = DriverManager::getConnection(['url' => 'pgsql://postgres:password@'.getenv('POSTGRES_HOST')]); + $conn = $this->createPostgreSqlConnection(); return new DoctrineDbalPostgreSqlStore($conn); } @@ -86,4 +94,76 @@ public function testSaveAfterConflict() $store2->save($key); $this->assertTrue($store2->exists($key)); } + + public function testWaitAndSaveAfterConflictReleasesLockFromInternalStore() + { + $store1 = $this->getStore(); + $conn = $this->createPostgreSqlConnection(); + $store2 = new DoctrineDbalPostgreSqlStore($conn); + + $keyId = uniqid(__METHOD__, true); + $store1Key = new Key($keyId); + + $store1->save($store1Key); + + // set a low time out then try to wait and save, which will fail + // because the key is already set above. + $conn->executeStatement('SET statement_timeout = 1'); + $waitSaveError = null; + try { + $store2->waitAndSave(new Key($keyId)); + } catch (DBALException $waitSaveError) { + } + $this->assertInstanceOf(DBALException::class, $waitSaveError, 'waitAndSave should have thrown'); + $conn->executeStatement('SET statement_timeout = 0'); + + $store1->delete($store1Key); + $this->assertFalse($store1->exists($store1Key)); + + $store2Key = new Key($keyId); + $lockConflicted = false; + try { + $store2->waitAndSave($store2Key); + } catch (LockConflictedException $lockConflictedException) { + $lockConflicted = true; + } + + $this->assertFalse($lockConflicted, 'lock should be available now that its been remove from $store1'); + $this->assertTrue($store2->exists($store2Key)); + } + + public function testWaitAndSaveReadAfterConflictReleasesLockFromInternalStore() + { + $store1 = $this->getStore(); + $conn = $this->createPostgreSqlConnection(); + $store2 = new DoctrineDbalPostgreSqlStore($conn); + + $keyId = uniqid(__METHOD__, true); + $store1Key = new Key($keyId); + + $store1->save($store1Key); + + // set a low time out then try to wait and save, which will fail + // because the key is already set above. + $conn->executeStatement('SET statement_timeout = 1'); + $waitSaveError = null; + try { + $store2->waitAndSaveRead(new Key($keyId)); + } catch (DBALException $waitSaveError) { + } + $this->assertInstanceOf(DBALException::class, $waitSaveError, 'waitAndSaveRead should have thrown'); + + $store1->delete($store1Key); + $this->assertFalse($store1->exists($store1Key)); + + $store2Key = new Key($keyId); + // since the lock is going to be acquired in read mode and is not exclusive + // this won't every throw a LockConflictedException as it would from + // waitAndSave, but it will hang indefinitely as it waits for postgres + // so set a time out of 2 seconds here so the test doesn't just sit forever + $conn->executeStatement('SET statement_timeout = 2000'); + $store2->waitAndSaveRead($store2Key); + + $this->assertTrue($store2->exists($store2Key)); + } } diff --git a/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php index aef6ee7b86782..a9cbf4d1eeed8 100644 --- a/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php @@ -28,16 +28,23 @@ class PostgreSqlStoreTest extends AbstractStoreTest use BlockingStoreTestTrait; use SharedLockStoreTestTrait; + public function getPostgresHost(): string + { + if (!$host = getenv('POSTGRES_HOST')) { + $this->markTestSkipped('Missing POSTGRES_HOST env variable'); + } + + return $host; + } + /** * {@inheritdoc} */ public function getStore(): PersistingStoreInterface { - if (!getenv('POSTGRES_HOST')) { - $this->markTestSkipped('Missing POSTGRES_HOST env variable'); - } + $host = $this->getPostgresHost(); - return new PostgreSqlStore('pgsql:host='.getenv('POSTGRES_HOST'), ['db_username' => 'postgres', 'db_password' => 'password']); + return new PostgreSqlStore('pgsql:host='.$host, ['db_username' => 'postgres', 'db_password' => 'password']); } /** @@ -78,4 +85,80 @@ public function testSaveAfterConflict() $store2->save($key); $this->assertTrue($store2->exists($key)); } + + public function testWaitAndSaveAfterConflictReleasesLockFromInternalStore() + { + $store1 = $this->getStore(); + $postgresHost = $this->getPostgresHost(); + $pdo = new \PDO('pgsql:host='.$postgresHost, 'postgres', 'password'); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $store2 = new PostgreSqlStore($pdo); + + $keyId = uniqid(__METHOD__, true); + $store1Key = new Key($keyId); + + $store1->save($store1Key); + + // set a low time out then try to wait and save, which will fail + // because the key is already set above. + $pdo->exec('SET statement_timeout = 1'); + $waitSaveError = null; + try { + $store2->waitAndSave(new Key($keyId)); + } catch (\PDOException $waitSaveError) { + } + $this->assertInstanceOf(\PDOException::class, $waitSaveError, 'waitAndSave should have thrown'); + $pdo->exec('SET statement_timeout = 0'); + + $store1->delete($store1Key); + $this->assertFalse($store1->exists($store1Key)); + + $store2Key = new Key($keyId); + $lockConflicted = false; + try { + $store2->waitAndSave($store2Key); + } catch (LockConflictedException $lockConflictedException) { + $lockConflicted = true; + } + + $this->assertFalse($lockConflicted, 'lock should be available now that its been remove from $store1'); + $this->assertTrue($store2->exists($store2Key)); + } + + public function testWaitAndSaveReadAfterConflictReleasesLockFromInternalStore() + { + $store1 = $this->getStore(); + $postgresHost = $this->getPostgresHost(); + $pdo = new \PDO('pgsql:host='.$postgresHost, 'postgres', 'password'); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $store2 = new PostgreSqlStore($pdo); + + $keyId = uniqid(__METHOD__, true); + $store1Key = new Key($keyId); + + $store1->save($store1Key); + + // set a low time out then try to wait and save, which will fail + // because the key is already set above. + $pdo->exec('SET statement_timeout = 1'); + $waitSaveError = null; + try { + $store2->waitAndSaveRead(new Key($keyId)); + } catch (\PDOException $waitSaveError) { + } + $this->assertInstanceOf(\PDOException::class, $waitSaveError, 'waitAndSave should have thrown'); + + $store1->delete($store1Key); + $this->assertFalse($store1->exists($store1Key)); + + $store2Key = new Key($keyId); + // since the lock is going to be acquired in read mode and is not exclusive + // this won't every throw a LockConflictedException as it would from + // waitAndSave, but it will hang indefinitely as it waits for postgres + // so set a time out of 2 seconds here so the test doesn't just sit forever + $pdo->exec('SET statement_timeout = 20000'); + $store2->waitAndSaveRead($store2Key); + + $this->assertTrue($store2->exists($store2Key)); + } }