diff --git a/README.md b/README.md index f1fbf6d..adb440a 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,13 @@ ## Introduction +> WARNING! This library is currently under development and may not be stable. Use in your services at your own risk. + PHP application-level database locking mechanisms to implement concurrency control patterns. Supported drivers: -- Postgres +- Postgres — [PostgreSQL Advisory Locks Documentation](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) ## Installation @@ -33,12 +35,16 @@ composer require cybercog/php-db-locker $dbConnection = new PDO($dsn, $username, $password); $postgresLocker = new \Cog\DbLocker\Locker\PostgresAdvisoryLocker(); -$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromLockId( - new LockId('user', '4'), -); +$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromKeyValue('user', '4'); $dbConnection->beginTransaction(); -$isLockAcquired = $postgresLocker->acquireLockWithinTransaction($dbConnection, $postgresLockId); +$isLockAcquired = $postgresLocker->acquireLock( + $dbConnection, + $postgresLockId, + \Cog\DbLocker\Locker\PostgresAdvisoryLockScopeEnum::Transaction, + \Cog\DbLocker\Locker\PostgresAdvisoryLockTypeEnum::NonBlocking, + \Cog\DbLocker\Locker\PostgresLockModeEnum::Exclusive, +); if ($isLockAcquired) { // Execute logic if lock was successful } else { @@ -53,11 +59,15 @@ $dbConnection->commit(); $dbConnection = new PDO($dsn, $username, $password); $postgresLocker = new \Cog\DbLocker\Locker\PostgresAdvisoryLocker(); -$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromLockId( - new LockId('user', '4'), +$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromKeyValue('user', '4'); + +$isLockAcquired = $postgresLocker->acquireLock( + $dbConnection, + $postgresLockId, + \Cog\DbLocker\Locker\PostgresAdvisoryLockScopeEnum::Session, + \Cog\DbLocker\Locker\PostgresAdvisoryLockTypeEnum::NonBlocking, + \Cog\DbLocker\Locker\PostgresLockModeEnum::Exclusive, ); - -$isLockAcquired = $postgresLocker->acquireLock($dbConnection, $postgresLockId); if ($isLockAcquired) { // Execute logic if lock was successful } else { diff --git a/src/LockId/PostgresLockId.php b/src/LockId/PostgresLockId.php index 650640c..b418746 100644 --- a/src/LockId/PostgresLockId.php +++ b/src/LockId/PostgresLockId.php @@ -17,30 +17,57 @@ final class PostgresLockId { - private const DB_INT64_VALUE_MIN = -9_223_372_036_854_775_808; - private const DB_INT64_VALUE_MAX = 9_223_372_036_854_775_807; + private const DB_INT32_VALUE_MIN = -2_147_483_648; private const DB_INT32_VALUE_MAX = 2_147_483_647; - public function __construct( - public readonly int $id, + private function __construct( + public readonly int $classId, + public readonly int $objectId, public readonly string $humanReadableValue = '', ) { - if ($id < self::DB_INT64_VALUE_MIN) { - throw new InvalidArgumentException('Out of bound exception (id is too small)'); + if ($classId < self::DB_INT32_VALUE_MIN) { + throw new InvalidArgumentException("Out of bound exception (classId=$classId is too small)"); } - if ($id > self::DB_INT64_VALUE_MAX) { - throw new InvalidArgumentException('Out of bound exception (id is too big)'); + if ($classId > self::DB_INT32_VALUE_MAX) { + throw new InvalidArgumentException("Out of bound exception (classId=$classId is too big)"); } + if ($objectId < self::DB_INT32_VALUE_MIN) { + throw new InvalidArgumentException("Out of bound exception (objectId=$objectId is too small)"); + } + if ($objectId > self::DB_INT32_VALUE_MAX) { + throw new InvalidArgumentException("Out of bound exception (objectId=$objectId is too big)"); + } + } + + public static function fromKeyValue( + string $key, + string $value = '', + ): self { + return self::fromLockId( + new LockId( + $key, + $value, + ), + ); } public static function fromLockId( LockId $lockId, ): self { - $lockStringId = (string)$lockId; + return new self( + classId: self::convertStringToSignedInt32($lockId->key), + objectId: self::convertStringToSignedInt32($lockId->value), + humanReadableValue: (string)$lockId, + ); + } + public static function fromIntKeys( + int $classId, + int $objectId, + ): self { return new self( - id: self::convertStringToSignedInt32($lockStringId), - humanReadableValue: $lockStringId, + $classId, + $objectId, ); } diff --git a/src/Locker/PostgresAdvisoryLockScopeEnum.php b/src/Locker/PostgresAdvisoryLockScopeEnum.php new file mode 100644 index 0000000..e573cc5 --- /dev/null +++ b/src/Locker/PostgresAdvisoryLockScopeEnum.php @@ -0,0 +1,17 @@ +prepare( - <<humanReadableValue - SQL, - ); - $statement->execute( - [ - 'lock_id' => $postgresLockId->id, - ], - ); - - return $statement->fetchColumn(0); - } - - public function tryAcquireLockWithinTransaction( - PDO $dbConnection, - PostgresLockId $postgresLockId, - ): bool { - if ($dbConnection->inTransaction() === false) { - $lockId = $postgresLockId->humanReadableValue; - + if ($scope === PostgresAdvisoryLockScopeEnum::Transaction && $dbConnection->inTransaction() === false) { throw new LogicException( - "Transaction-level advisory lock `$lockId` cannot be acquired outside of transaction", + "Transaction-level advisory lock `$postgresLockId->humanReadableValue` cannot be acquired outside of transaction", ); } - // TODO: Need to sanitize humanReadableValue? - $statement = $dbConnection->prepare( - <<humanReadableValue - SQL, - ); + $sql = match ([$scope, $type, $mode]) { + [ + PostgresAdvisoryLockScopeEnum::Transaction, + PostgresAdvisoryLockTypeEnum::NonBlocking, + PostgresLockModeEnum::Exclusive, + ] => 'SELECT PG_TRY_ADVISORY_XACT_LOCK(:class_id, :object_id);', + [ + PostgresAdvisoryLockScopeEnum::Transaction, + PostgresAdvisoryLockTypeEnum::Blocking, + PostgresLockModeEnum::Exclusive, + ] => 'SELECT PG_ADVISORY_XACT_LOCK(:class_id, :object_id);', + [ + PostgresAdvisoryLockScopeEnum::Transaction, + PostgresAdvisoryLockTypeEnum::NonBlocking, + PostgresLockModeEnum::Share, + ] => 'SELECT PG_TRY_ADVISORY_XACT_LOCK_SHARE(:class_id, :object_id);', + [ + PostgresAdvisoryLockScopeEnum::Transaction, + PostgresAdvisoryLockTypeEnum::Blocking, + PostgresLockModeEnum::Share, + ] => 'SELECT PG_ADVISORY_XACT_LOCK_SHARE(:class_id, :object_id);', + [ + PostgresAdvisoryLockScopeEnum::Session, + PostgresAdvisoryLockTypeEnum::NonBlocking, + PostgresLockModeEnum::Exclusive, + ] => 'SELECT PG_TRY_ADVISORY_LOCK(:class_id, :object_id);', + [ + PostgresAdvisoryLockScopeEnum::Session, + PostgresAdvisoryLockTypeEnum::Blocking, + PostgresLockModeEnum::Exclusive, + ] => 'SELECT PG_ADVISORY_LOCK(:class_id, :object_id);', + [ + PostgresAdvisoryLockScopeEnum::Session, + PostgresAdvisoryLockTypeEnum::NonBlocking, + PostgresLockModeEnum::Share, + ] => 'SELECT PG_TRY_ADVISORY_LOCK_SHARE(:class_id, :object_id);', + [ + PostgresAdvisoryLockScopeEnum::Session, + PostgresAdvisoryLockTypeEnum::Blocking, + PostgresLockModeEnum::Share, + ] => 'SELECT PG_ADVISORY_LOCK_SHARE(:class_id, :object_id);', + }; + $sql .= " -- $postgresLockId->humanReadableValue"; + + $statement = $dbConnection->prepare($sql); $statement->execute( [ - 'lock_id' => $postgresLockId->id, + 'class_id' => $postgresLockId->classId, + 'object_id' => $postgresLockId->objectId, ], ); return $statement->fetchColumn(0); } + /** + * Release session level advisory lock. + */ public function releaseLock( PDO $dbConnection, PostgresLockId $postgresLockId, + PostgresAdvisoryLockScopeEnum $scope = PostgresAdvisoryLockScopeEnum::Session, ): bool { + if ($scope === PostgresAdvisoryLockScopeEnum::Transaction) { + throw new \InvalidArgumentException('Transaction-level advisory lock cannot be released'); + } + $statement = $dbConnection->prepare( <<humanReadableValue + SELECT PG_ADVISORY_UNLOCK(:class_id, :object_id); -- $postgresLockId->humanReadableValue SQL, ); $statement->execute( [ - 'lock_id' => $postgresLockId->id, + 'class_id' => $postgresLockId->classId, + 'object_id' => $postgresLockId->objectId, ], ); return $statement->fetchColumn(0); } + /** + * Release all session level advisory locks held by the current session. + */ public function releaseAllLocks( PDO $dbConnection, + PostgresAdvisoryLockScopeEnum $scope = PostgresAdvisoryLockScopeEnum::Session, ): void { + if ($scope === PostgresAdvisoryLockScopeEnum::Transaction) { + throw new \InvalidArgumentException('Transaction-level advisory lock cannot be released'); + } + $statement = $dbConnection->prepare( <<<'SQL' SELECT PG_ADVISORY_UNLOCK_ALL(); diff --git a/src/Locker/PostgresLockModeEnum.php b/src/Locker/PostgresLockModeEnum.php new file mode 100644 index 0000000..21900a3 --- /dev/null +++ b/src/Locker/PostgresLockModeEnum.php @@ -0,0 +1,14 @@ +closeAllPostgresPdoConnections(); @@ -91,16 +89,6 @@ private function findPostgresAdvisoryLockInConnection( ): object | null { // For one-argument advisory locks, Postgres stores the signed 64-bit key as two 32-bit integers: // classid = high 32 bits, objid = low 32 bits. - $lockClassId = ($postgresLockId->id >> 32) & 0xFFFFFFFF; - $lockObjectId = $postgresLockId->id & 0xFFFFFFFF; - - // Convert to signed 32-bit if necessary (Postgres stores as signed) - if ($lockClassId > 0x7FFFFFFF) { - $lockClassId -= 0x100000000; - } - if ($lockObjectId > 0x7FFFFFFF) { - $lockObjectId -= 0x100000000; - } $statement = $dbConnection->prepare( <<<'SQL' @@ -116,11 +104,11 @@ private function findPostgresAdvisoryLockInConnection( ); $statement->execute( [ - 'lock_class_id' => $lockClassId, - 'lock_object_id' => $lockObjectId, - 'lock_object_subid' => 1, // For one keyed value + 'lock_class_id' => $postgresLockId->classId, + 'lock_object_id' => $postgresLockId->objectId, + 'lock_object_subid' => 2, // Using two keyed locks 'connection_pid' => $dbConnection->pgsqlGetPid(), - 'mode' => self::MODE_EXCLUSIVE, + 'mode' => PostgresLockModeEnum::Exclusive->value, ], ); @@ -147,7 +135,7 @@ private function findAllPostgresAdvisoryLocks(): array ); $statement->execute( [ - 'mode' => self::MODE_EXCLUSIVE, + 'mode' => PostgresLockModeEnum::Exclusive->value, ], ); diff --git a/test/Integration/Locker/PostgresAdvisoryLockerTest.php b/test/Integration/Locker/PostgresAdvisoryLockerTest.php index f7628c1..ee3ea71 100644 --- a/test/Integration/Locker/PostgresAdvisoryLockerTest.php +++ b/test/Integration/Locker/PostgresAdvisoryLockerTest.php @@ -14,108 +14,155 @@ namespace Cog\Test\DbLocker\Integration\Locker; use Cog\DbLocker\Locker\PostgresAdvisoryLocker; -use Cog\DbLocker\LockId\LockId; +use Cog\DbLocker\Locker\PostgresAdvisoryLockScopeEnum; use Cog\DbLocker\LockId\PostgresLockId; use Cog\Test\DbLocker\Integration\AbstractIntegrationTestCase; use LogicException; +use PHPUnit\Framework\Attributes\DataProvider; final class PostgresAdvisoryLockerTest extends AbstractIntegrationTestCase { - private const DB_INT64_VALUE_MIN = 0; - private const DB_INT64_VALUE_MAX = 9223372036854775807; + private const DB_INT32_VALUE_MIN = -2_147_483_648; + private const DB_INT32_VALUE_MAX = 2_147_483_647; - public function test_it_can_acquire_lock(): void + public function testItCanTryAcquireLockWithinSession(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); - $isLockAcquired = $locker->tryAcquireLock($dbConnection, $postgresLockId); + $isLockAcquired = $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertTrue($isLockAcquired); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); $this->assertPgAdvisoryLocksCount(1); + $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); } - public function test_it_can_acquire_lock_with_smallest_lock_id(): void + #[DataProvider('provideItCanTryAcquireLockFromIntKeysCornerCasesData')] + public function testItCanTryAcquireLockFromIntKeysCornerCases(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = new PostgresLockId(self::DB_INT64_VALUE_MIN); + $postgresLockId = PostgresLockId::fromIntKeys(self::DB_INT32_VALUE_MIN, 0); - $isLockAcquired = $locker->tryAcquireLock($dbConnection, $postgresLockId); + $isLockAcquired = $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertTrue($isLockAcquired); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); $this->assertPgAdvisoryLocksCount(1); + $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); } - public function test_it_can_acquire_lock_with_biggest_lock_id(): void + public static function provideItCanTryAcquireLockFromIntKeysCornerCasesData(): array { - $locker = $this->initLocker(); - $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = new PostgresLockId(self::DB_INT64_VALUE_MAX); - - $isLockAcquired = $locker->tryAcquireLock($dbConnection, $postgresLockId); - - $this->assertTrue($isLockAcquired); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); - $this->assertPgAdvisoryLocksCount(1); + return [ + 'min class_id' => [ + self::DB_INT32_VALUE_MIN, + 0, + ], + 'max class_id' => [ + self::DB_INT32_VALUE_MAX, + 0, + ], + 'min object_id' => [ + 0, + self::DB_INT32_VALUE_MIN, + ], + 'max object_id' => [ + 0, + self::DB_INT32_VALUE_MAX, + ], + ]; } - public function test_it_can_acquire_lock_in_same_connection_only_once(): void + public function testItCanTryAcquireLockInSameConnectionOnlyOnce(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); - $isLockAcquired1 = $locker->tryAcquireLock($dbConnection, $postgresLockId); - $isLockAcquired2 = $locker->tryAcquireLock($dbConnection, $postgresLockId); + $isLockAcquired1 = $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); + $isLockAcquired2 = $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertTrue($isLockAcquired1); $this->assertTrue($isLockAcquired2); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); $this->assertPgAdvisoryLocksCount(1); + $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); } - public function test_it_can_acquire_multiple_locks_in_one_connection(): void + public function testItCanTryAcquireMultipleLocksInOneConnection(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId1 = $this->initPostgresLockId('test1'); - $postgresLockId2 = $this->initPostgresLockId('test2'); + $postgresLockId1 = PostgresLockId::fromKeyValue('test1'); + $postgresLockId2 = PostgresLockId::fromKeyValue('test2'); - $isLock1Acquired = $locker->tryAcquireLock($dbConnection, $postgresLockId1); - $isLock2Acquired = $locker->tryAcquireLock($dbConnection, $postgresLockId2); + $isLock1Acquired = $locker->acquireLock( + $dbConnection, + $postgresLockId1, + PostgresAdvisoryLockScopeEnum::Session, + ); + $isLock2Acquired = $locker->acquireLock( + $dbConnection, + $postgresLockId2, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertTrue($isLock1Acquired); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId1); $this->assertTrue($isLock2Acquired); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId2); $this->assertPgAdvisoryLocksCount(2); + $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId1); + $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId2); } - public function test_it_cannot_acquire_same_lock_in_two_connections(): void + public function testItCannotAcquireSameLockInTwoConnections(): void { $locker = $this->initLocker(); $dbConnection1 = $this->initPostgresPdoConnection(); $dbConnection2 = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); - $locker->tryAcquireLock($dbConnection1, $postgresLockId); + $postgresLockId = PostgresLockId::fromKeyValue('test'); + $locker->acquireLock( + $dbConnection1, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); - $isLockAcquired = $locker->tryAcquireLock($dbConnection2, $postgresLockId); + $isLockAcquired = $locker->acquireLock( + $dbConnection2, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertFalse($isLockAcquired); $this->assertPgAdvisoryLocksCount(1); $this->assertPgAdvisoryLockMissingInConnection($dbConnection2, $postgresLockId); } - public function test_it_can_release_lock(): void + public function testItCanReleaseLock(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); - $locker->tryAcquireLock($dbConnection, $postgresLockId); + $postgresLockId = PostgresLockId::fromKeyValue('test'); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $isLockReleased = $locker->releaseLock($dbConnection, $postgresLockId); @@ -123,13 +170,21 @@ public function test_it_can_release_lock(): void $this->assertPgAdvisoryLocksCount(0); } - public function test_it_can_release_lock_twice_if_acquired_twice(): void + public function testItCanReleaseLockTwiceIfAcquiredTwice(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); - $locker->tryAcquireLock($dbConnection, $postgresLockId); - $locker->tryAcquireLock($dbConnection, $postgresLockId); + $postgresLockId = PostgresLockId::fromKeyValue('test'); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $isLockReleased1 = $locker->releaseLock($dbConnection, $postgresLockId); $isLockReleased2 = $locker->releaseLock($dbConnection, $postgresLockId); @@ -139,33 +194,56 @@ public function test_it_can_release_lock_twice_if_acquired_twice(): void $this->assertPgAdvisoryLocksCount(0); } - public function test_it_can_acquire_lock_in_second_connection_after_release(): void + public function testItCanTryAcquireLockInSecondConnectionAfterRelease(): void { $locker = $this->initLocker(); $dbConnection1 = $this->initPostgresPdoConnection(); $dbConnection2 = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); - $locker->tryAcquireLock($dbConnection1, $postgresLockId); - $locker->releaseLock($dbConnection1, $postgresLockId); + $postgresLockId = PostgresLockId::fromKeyValue('test'); + $locker->acquireLock( + $dbConnection1, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); + $locker->releaseLock( + $dbConnection1, + $postgresLockId, + ); - $isLockAcquired = $locker->tryAcquireLock($dbConnection2, $postgresLockId); + $isLockAcquired = $locker->acquireLock( + $dbConnection2, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertTrue($isLockAcquired); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection2, $postgresLockId); $this->assertPgAdvisoryLocksCount(1); + $this->assertPgAdvisoryLockExistsInConnection($dbConnection2, $postgresLockId); } - public function test_it_cannot_acquire_lock_in_second_connection_after_one_release_twice_locked(): void + public function testItCannotAcquireLockInSecondConnectionAfterOneReleaseTwiceLocked(): void { $locker = $this->initLocker(); $dbConnection1 = $this->initPostgresPdoConnection(); $dbConnection2 = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); - $locker->tryAcquireLock($dbConnection1, $postgresLockId); - $locker->tryAcquireLock($dbConnection1, $postgresLockId); + $postgresLockId = PostgresLockId::fromKeyValue('test'); + $locker->acquireLock( + $dbConnection1, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); + $locker->acquireLock( + $dbConnection1, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $isLockReleased = $locker->releaseLock($dbConnection1, $postgresLockId); - $isLockAcquired = $locker->tryAcquireLock($dbConnection2, $postgresLockId); + $isLockAcquired = $locker->acquireLock( + $dbConnection2, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertTrue($isLockReleased); $this->assertFalse($isLockAcquired); @@ -174,11 +252,11 @@ public function test_it_cannot_acquire_lock_in_second_connection_after_one_relea $this->assertPgAdvisoryLockMissingInConnection($dbConnection2, $postgresLockId); } - public function test_it_cannot_release_lock_if_not_acquired(): void + public function testItCannotReleaseLockIfNotAcquired(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); $isLockReleased = $locker->releaseLock($dbConnection, $postgresLockId); @@ -186,36 +264,46 @@ public function test_it_cannot_release_lock_if_not_acquired(): void $this->assertPgAdvisoryLocksCount(0); } - public function test_it_cannot_release_lock_if_acquired_in_other_connection(): void + public function testItCannotReleaseLockIfAcquiredInOtherConnection(): void { $locker = $this->initLocker(); $dbConnection1 = $this->initPostgresPdoConnection(); $dbConnection2 = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); - $locker->tryAcquireLock($dbConnection1, $postgresLockId); + $postgresLockId = PostgresLockId::fromKeyValue('test'); + $locker->acquireLock( + $dbConnection1, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $isLockReleased = $locker->releaseLock($dbConnection2, $postgresLockId); $this->assertFalse($isLockReleased); - $this->assertPgAdvisoryLockExistsInConnection($dbConnection1, $postgresLockId); $this->assertPgAdvisoryLocksCount(1); + $this->assertPgAdvisoryLockExistsInConnection($dbConnection1, $postgresLockId); } - public function test_it_can_release_all_locks_in_connection(): void + public function testItCanReleaseAllLocksInConnection(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId1 = $this->initPostgresLockId('test'); - $postgresLockId2 = $this->initPostgresLockId('test2'); - $locker->tryAcquireLock($dbConnection, $postgresLockId1); - $locker->tryAcquireLock($dbConnection, $postgresLockId2); + $locker->acquireLock( + $dbConnection, + PostgresLockId::fromKeyValue('test'), + PostgresAdvisoryLockScopeEnum::Session, + ); + $locker->acquireLock( + $dbConnection, + PostgresLockId::fromKeyValue('test2'), + PostgresAdvisoryLockScopeEnum::Session, + ); $locker->releaseAllLocks($dbConnection); $this->assertPgAdvisoryLocksCount(0); } - public function test_it_can_release_all_locks_in_connection_if_no_locks_were_acquired(): void + public function testItCanReleaseAllLocksInConnectionIfNoLocksWereAcquired(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); @@ -225,19 +313,35 @@ public function test_it_can_release_all_locks_in_connection_if_no_locks_were_acq $this->assertPgAdvisoryLocksCount(0); } - public function test_it_can_release_all_locks_in_connection_but_keeps_other_locks(): void + public function testItCanReleaseAllLocksInConnectionButKeepsOtherLocks(): void { $locker = $this->initLocker(); $dbConnection1 = $this->initPostgresPdoConnection(); $dbConnection2 = $this->initPostgresPdoConnection(); - $postgresLockId1 = $this->initPostgresLockId('test'); - $postgresLockId2 = $this->initPostgresLockId('test2'); - $postgresLockId3 = $this->initPostgresLockId('test3'); - $postgresLockId4 = $this->initPostgresLockId('test4'); - $locker->tryAcquireLock($dbConnection1, $postgresLockId1); - $locker->tryAcquireLock($dbConnection1, $postgresLockId2); - $locker->tryAcquireLock($dbConnection2, $postgresLockId3); - $locker->tryAcquireLock($dbConnection2, $postgresLockId4); + $postgresLockId1 = PostgresLockId::fromKeyValue('test'); + $postgresLockId2 = PostgresLockId::fromKeyValue('test2'); + $postgresLockId3 = PostgresLockId::fromKeyValue('test3'); + $postgresLockId4 = PostgresLockId::fromKeyValue('test4'); + $locker->acquireLock( + $dbConnection1, + $postgresLockId1, + PostgresAdvisoryLockScopeEnum::Session, + ); + $locker->acquireLock( + $dbConnection1, + $postgresLockId2, + PostgresAdvisoryLockScopeEnum::Session, + ); + $locker->acquireLock( + $dbConnection2, + $postgresLockId3, + PostgresAdvisoryLockScopeEnum::Session, + ); + $locker->acquireLock( + $dbConnection2, + $postgresLockId4, + PostgresAdvisoryLockScopeEnum::Session, + ); $locker->releaseAllLocks($dbConnection1); @@ -246,21 +350,25 @@ public function test_it_can_release_all_locks_in_connection_but_keeps_other_lock $this->assertPgAdvisoryLockExistsInConnection($dbConnection2, $postgresLockId4); } - public function test_it_can_acquire_lock_within_transaction(): void + public function testItCanTryAcquireLockWithinTransaction(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); $dbConnection->beginTransaction(); - $isLockAcquired = $locker->tryAcquireLockWithinTransaction($dbConnection, $postgresLockId); + $isLockAcquired = $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertTrue($isLockAcquired); $this->assertPgAdvisoryLocksCount(1); $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); } - public function test_it_cannot_acquire_lock_within_transaction_not_in_transaction(): void + public function testItCannotAcquireLockWithinTransactionNotInTransaction(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage( @@ -269,34 +377,50 @@ public function test_it_cannot_acquire_lock_within_transaction_not_in_transactio $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); - $locker->tryAcquireLockWithinTransaction($dbConnection, $postgresLockId); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Transaction, + ); } - public function test_it_cannot_acquire_lock_in_second_connection_if_taken_within_transaction(): void + public function testItCannotAcquireLockInSecondConnectionIfTakenWithinTransaction(): void { $locker = $this->initLocker(); $dbConnection1 = $this->initPostgresPdoConnection(); $dbConnection2 = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); $dbConnection1->beginTransaction(); - $locker->tryAcquireLockWithinTransaction($dbConnection1, $postgresLockId); + $locker->acquireLock( + $dbConnection1, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); - $isLockAcquired = $locker->tryAcquireLock($dbConnection2, $postgresLockId); + $isLockAcquired = $locker->acquireLock( + $dbConnection2, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Session, + ); $this->assertFalse($isLockAcquired); $this->assertPgAdvisoryLocksCount(1); $this->assertPgAdvisoryLockExistsInConnection($dbConnection1, $postgresLockId); } - public function test_it_can_auto_release_lock_acquired_within_transaction_on_commit(): void + public function testItCanAutoReleaseLockAcquiredWithinTransactionOnCommit(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); $dbConnection->beginTransaction(); - $locker->tryAcquireLockWithinTransaction($dbConnection, $postgresLockId); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Transaction, + ); $dbConnection->commit(); @@ -304,13 +428,17 @@ public function test_it_can_auto_release_lock_acquired_within_transaction_on_com $this->assertPgAdvisoryLockMissingInConnection($dbConnection, $postgresLockId); } - public function test_it_can_auto_release_lock_acquired_within_transaction_on_rollback(): void + public function testItCanAutoReleaseLockAcquiredWithinTransactionOnRollback(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); $dbConnection->beginTransaction(); - $locker->tryAcquireLockWithinTransaction($dbConnection, $postgresLockId); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Transaction, + ); $dbConnection->rollBack(); @@ -318,26 +446,34 @@ public function test_it_can_auto_release_lock_acquired_within_transaction_on_rol $this->assertPgAdvisoryLockMissingInConnection($dbConnection, $postgresLockId); } - public function test_it_can_auto_release_lock_acquired_within_transaction_on_connection_kill(): void + public function testItCanAutoReleaseLockAcquiredWithinTransactionOnConnectionKill(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); $dbConnection->beginTransaction(); - $locker->tryAcquireLockWithinTransaction($dbConnection, $postgresLockId); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Transaction, + ); $dbConnection = null; $this->assertPgAdvisoryLocksCount(0); } - public function test_it_cannot_release_lock_acquired_within_transaction(): void + public function testItCannotReleaseLockAcquiredWithinTransaction(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId = $this->initPostgresLockId('test'); + $postgresLockId = PostgresLockId::fromKeyValue('test'); $dbConnection->beginTransaction(); - $locker->tryAcquireLockWithinTransaction($dbConnection, $postgresLockId); + $locker->acquireLock( + $dbConnection, + $postgresLockId, + PostgresAdvisoryLockScopeEnum::Transaction, + ); $isLockReleased = $locker->releaseLock($dbConnection, $postgresLockId); @@ -346,15 +482,23 @@ public function test_it_cannot_release_lock_acquired_within_transaction(): void $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId); } - public function test_it_cannot_release_all_locks_acquired_within_transaction(): void + public function testItCannotReleaseAllLocksAcquiredWithinTransaction(): void { $locker = $this->initLocker(); $dbConnection = $this->initPostgresPdoConnection(); - $postgresLockId1 = $this->initPostgresLockId('test'); - $postgresLockId2 = $this->initPostgresLockId('test2'); - $locker->tryAcquireLock($dbConnection, $postgresLockId1); + $postgresLockId1 = PostgresLockId::fromKeyValue('test'); + $postgresLockId2 = PostgresLockId::fromKeyValue('test2'); + $locker->acquireLock( + $dbConnection, + $postgresLockId1, + PostgresAdvisoryLockScopeEnum::Session, + ); $dbConnection->beginTransaction(); - $locker->tryAcquireLockWithinTransaction($dbConnection, $postgresLockId2); + $locker->acquireLock( + $dbConnection, + $postgresLockId2, + PostgresAdvisoryLockScopeEnum::Transaction, + ); $locker->releaseAllLocks($dbConnection); @@ -363,14 +507,71 @@ public function test_it_cannot_release_all_locks_acquired_within_transaction(): $this->assertPgAdvisoryLockExistsInConnection($dbConnection, $postgresLockId2); } - private function initLocker(): PostgresAdvisoryLocker + public function testItCannotReleaseAllLocksWithTransactionScope(): void { - return new PostgresAdvisoryLocker(); + $locker = $this->initLocker(); + $dbConnection = $this->initPostgresPdoConnection(); + $postgresLockId1 = PostgresLockId::fromKeyValue('test'); + $postgresLockId2 = PostgresLockId::fromKeyValue('test2'); + $locker->acquireLock( + $dbConnection, + $postgresLockId1, + PostgresAdvisoryLockScopeEnum::Session, + ); + $dbConnection->beginTransaction(); + $locker->acquireLock( + $dbConnection, + $postgresLockId2, + PostgresAdvisoryLockScopeEnum::Transaction, + ); + + try { + $locker->releaseAllLocks( + $dbConnection, + PostgresAdvisoryLockScopeEnum::Transaction, + ); + } catch (\InvalidArgumentException $exception) { + $this->assertSame( + 'Transaction-level advisory lock cannot be released', + $exception->getMessage(), + ); + } + } + + public function testItCannotReleaseLocksWithTransactionScope(): void + { + $locker = $this->initLocker(); + $dbConnection = $this->initPostgresPdoConnection(); + $postgresLockId1 = PostgresLockId::fromKeyValue('test'); + $postgresLockId2 = PostgresLockId::fromKeyValue('test2'); + $locker->acquireLock( + $dbConnection, + $postgresLockId1, + PostgresAdvisoryLockScopeEnum::Session, + ); + $dbConnection->beginTransaction(); + $locker->acquireLock( + $dbConnection, + $postgresLockId2, + PostgresAdvisoryLockScopeEnum::Transaction, + ); + + try { + $locker->releaseLock( + $dbConnection, + $postgresLockId2, + PostgresAdvisoryLockScopeEnum::Transaction, + ); + } catch (\InvalidArgumentException $exception) { + $this->assertSame( + 'Transaction-level advisory lock cannot be released', + $exception->getMessage(), + ); + } } - private function initPostgresLockId( - string $lockKey, - ): PostgresLockId { - return PostgresLockId::fromLockId(new LockId($lockKey)); + private function initLocker(): PostgresAdvisoryLocker + { + return new PostgresAdvisoryLocker(); } } diff --git a/test/Unit/LockId/LockIdTest.php b/test/Unit/LockId/LockIdTest.php index dfc028b..a22f95c 100644 --- a/test/Unit/LockId/LockIdTest.php +++ b/test/Unit/LockId/LockIdTest.php @@ -16,45 +16,50 @@ use Cog\DbLocker\LockId\LockId; use Cog\Test\DbLocker\Unit\AbstractUnitTestCase; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; final class LockIdTest extends AbstractUnitTestCase { - public function test_it_can_create_lock_id(): void - { - $lockId = new LockId('test'); - - $this->assertSame('test', (string)$lockId); - } - - public function test_it_can_create_lock_id_with_space_key(): void - { - $lockId = new LockId(' '); - - $this->assertSame(' ', (string)$lockId); + #[DataProvider('provideItCanCreateLockIdData')] + public function testItCanCreateLockId( + string $key, + string $value, + string $expectedCompiledId, + ): void { + $lockId = new LockId($key, $value); + + $this->assertSame($key, $lockId->key); + $this->assertSame($value, $lockId->value); + $this->assertSame($expectedCompiledId, (string)$lockId); } - public function test_it_can_create_lock_id_with_spaced_key(): void + public static function provideItCanCreateLockIdData(): array { - $lockId = new LockId(' test '); - - $this->assertSame(' test ', (string)$lockId); - } - - public function test_it_can_create_lock_id_with_value(): void - { - $lockId = new LockId('test', '1'); - - $this->assertSame('test:1', (string)$lockId); - } - - public function test_it_can_create_lock_id_with_value_and_spaced_key(): void - { - $lockId = new LockId(' test ', '1'); - - $this->assertSame(' test :1', (string)$lockId); + return [ + 'key only' => [ + 'test', + '', + 'test', + ], + 'key space' => [ + ' ', + '', + ' ', + ], + 'key space + value space' => [ + ' ', + ' ', + ' : ', + ], + 'key + value' => [ + ' test ', + ' 12 ', + ' test : 12 ', + ], + ]; } - public function test_it_cannot_create_lock_id_with_empty_key(): void + public function testItCannotCreateLockIdWithEmptyKey(): void { $this->expectException(InvalidArgumentException::class); diff --git a/test/Unit/LockId/PostgresLockIdTest.php b/test/Unit/LockId/PostgresLockIdTest.php index 5a73607..b1911ae 100644 --- a/test/Unit/LockId/PostgresLockIdTest.php +++ b/test/Unit/LockId/PostgresLockIdTest.php @@ -16,41 +16,143 @@ use Cog\DbLocker\LockId\LockId; use Cog\DbLocker\LockId\PostgresLockId; use Cog\Test\DbLocker\Unit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; final class PostgresLockIdTest extends AbstractUnitTestCase { - private const DB_INT64_VALUE_MIN = 0; - private const DB_INT64_VALUE_MAX = 9223372036854775807; + private const DB_INT32_VALUE_MIN = -2_147_483_648; + private const DB_INT32_VALUE_MAX = 2_147_483_647; - public function test_it_can_create_postgres_lock_id_with_min_id(): void - { - $lockId = new PostgresLockId(self::DB_INT64_VALUE_MIN); + #[DataProvider('provideItCanCreatePostgresLockIdFromKeyValueData')] + public function testItCanCreatePostgresLockIdFromKeyValue( + string $key, + string $value, + int $expectedClassId, + int $expectedObjectId, + ): void { + $postgresLockId = PostgresLockId::fromKeyValue($key, $value); - $this->assertSame(self::DB_INT64_VALUE_MIN, $lockId->id); + $this->assertSame($expectedClassId, $postgresLockId->classId); + $this->assertSame($expectedObjectId, $postgresLockId->objectId); } - public function test_it_can_create_postgres_lock_id_with_max_id(): void + public static function provideItCanCreatePostgresLockIdFromKeyValueData(): array { - $lockId = new PostgresLockId(self::DB_INT64_VALUE_MAX); + return [ + 'key + empty value' => [ + 'test', + '', + -662733300, + 0, + ], + 'key + value' => [ + 'test', + '1', + -662733300, + -2082672713, + ], + ]; + } + + #[DataProvider('provideItCanCreatePostgresLockIdFromLockIdData')] + public function testItCanCreatePostgresLockIdFromLockId( + LockId $lockId, + int $expectedClassId, + int $expectedObjectId, + ): void { + $postgresLockId = PostgresLockId::fromLockId($lockId); - $this->assertSame(self::DB_INT64_VALUE_MAX, $lockId->id); + $this->assertSame($expectedClassId, $postgresLockId->classId); + $this->assertSame($expectedObjectId, $postgresLockId->objectId); } - public function test_it_can_create_postgres_lock_id_from_lock_id(): void + public static function provideItCanCreatePostgresLockIdFromLockIdData(): array { - $lockId = new LockId('test'); + return [ + 'key only' => [ + new LockId('test'), + -662733300, + 0, + ], + 'key + value' => [ + new LockId('test', '1'), + -662733300, + -2082672713, + ], + ]; + } - $postgresLockId = PostgresLockId::fromLockId($lockId); + #[DataProvider('provideItCanCreatePostgresLockIdFromIntKeysData')] + public function testItCanCreatePostgresLockIdFromIntKeys( + int $classId, + int $objectId, + ): void { + $lockId = PostgresLockId::fromIntKeys($classId, $objectId); - $this->assertSame(-662733300, $postgresLockId->id); + $this->assertSame($classId, $lockId->classId); + $this->assertSame($objectId, $lockId->objectId); } - public function test_it_can_create_postgres_lock_id_from_lock_id_with_value(): void + public static function provideItCanCreatePostgresLockIdFromIntKeysData(): array { - $lockId = new LockId('test', '1'); + return [ + 'min class_id' => [ + self::DB_INT32_VALUE_MIN, + 0, + ], + 'max class_id' => [ + self::DB_INT32_VALUE_MAX, + 0, + ], + 'min object_id' => [ + 0, + self::DB_INT32_VALUE_MIN, + ], + 'max object_id' => [ + 0, + self::DB_INT32_VALUE_MAX, + ], + ]; + } - $postgresLockId = PostgresLockId::fromLockId($lockId); + #[DataProvider('provideItCanCreatePostgresLockIdFromOutOfRangeIntKeysData')] + public function testItCanNotCreatePostgresLockIdFromOutOfRangeIntKeys( + int $classId, + int $objectId, + string $expectedExceptionMessage, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $lockId = PostgresLockId::fromIntKeys($classId, $objectId); - $this->assertSame(782632948, $postgresLockId->id); + $this->assertSame($classId, $lockId->classId); + $this->assertSame($objectId, $lockId->objectId); + } + + public static function provideItCanCreatePostgresLockIdFromOutOfRangeIntKeysData(): array + { + return [ + 'min class_id' => [ + self::DB_INT32_VALUE_MIN - 1, + 0, + "Out of bound exception (classId=-2147483649 is too small)" + ], + 'max class_id' => [ + self::DB_INT32_VALUE_MAX + 1, + 0, + "Out of bound exception (classId=2147483648 is too big)" + ], + 'min object_id' => [ + 0, + self::DB_INT32_VALUE_MIN - 1, + "Out of bound exception (objectId=-2147483649 is too small)" + ], + 'max object_id' => [ + 0, + self::DB_INT32_VALUE_MAX + 1, + "Out of bound exception (objectId=2147483648 is too big)" + ], + ]; } }