Skip to content
Merged
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
49 changes: 38 additions & 11 deletions src/LockId/PostgresLockId.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down
17 changes: 17 additions & 0 deletions src/Locker/PostgresAdvisoryLockScopeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Cog\DbLocker\Locker;

/**
* PostgresAdvisoryLockScopeEnum defines the scope of advisory lock acquisition.
*
* - Session: Session-level advisory lock (PG_ADVISORY_LOCK, PG_TRY_ADVISORY_LOCK)
* - Transaction: Transaction-level advisory lock (PG_ADVISORY_XACT_LOCK, PG_TRY_ADVISORY_XACT_LOCK)
*/
enum PostgresAdvisoryLockScopeEnum
{
case Session;
case Transaction;
}
17 changes: 17 additions & 0 deletions src/Locker/PostgresAdvisoryLockTypeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Cog\DbLocker\Locker;

/**
* PostgresAdvisoryLockTypeEnum defines the type of advisory lock acquisition.
*
* - NonBlocking: Attempt to acquire the lock without blocking (PG_TRY_ADVISORY_LOCK, PG_TRY_ADVISORY_XACT_LOCK).
* - Blocking: Acquire the lock, blocking until it becomes available (PG_ADVISORY_LOCK, PG_ADVISORY_XACT_LOCK).
*/
enum PostgresAdvisoryLockTypeEnum
{
case NonBlocking;
case Blocking;
}
108 changes: 75 additions & 33 deletions src/Locker/PostgresAdvisoryLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,73 +19,115 @@

final class PostgresAdvisoryLocker
{
public function tryAcquireLock(
/**
* Acquire an advisory lock with configurable scope, mode and behavior.
*/
public function acquireLock(
PDO $dbConnection,
PostgresLockId $postgresLockId,
PostgresAdvisoryLockScopeEnum $scope = PostgresAdvisoryLockScopeEnum::Transaction,
PostgresAdvisoryLockTypeEnum $type = PostgresAdvisoryLockTypeEnum::NonBlocking,
PostgresLockModeEnum $mode = PostgresLockModeEnum::Exclusive,
): bool {
// TODO: Need to sanitize humanReadableValue?
$statement = $dbConnection->prepare(
<<<SQL
SELECT PG_TRY_ADVISORY_LOCK(:lock_id); -- $postgresLockId->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(
<<<SQL
SELECT PG_TRY_ADVISORY_XACT_LOCK(:lock_id); -- $postgresLockId->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(
<<<SQL
SELECT PG_ADVISORY_UNLOCK(:lock_id); -- $postgresLockId->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();
Expand Down
14 changes: 14 additions & 0 deletions src/Locker/PostgresLockModeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Cog\DbLocker\Locker;

/**
* PostgresLockModeEnum defines the access mode of advisory lock acquisition.
*/
enum PostgresLockModeEnum: string
{
case Exclusive = 'ExclusiveLock';
case Share = 'ShareLock';
}
24 changes: 6 additions & 18 deletions test/Integration/AbstractIntegrationTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,13 @@

namespace Cog\Test\DbLocker\Integration;

use Cog\DbLocker\Locker\PostgresLockModeEnum;
use Cog\DbLocker\LockId\PostgresLockId;
use PDO;
use PHPUnit\Framework\TestCase;

abstract class AbstractIntegrationTestCase extends TestCase
{
private const MODE_EXCLUSIVE = 'ExclusiveLock';
private const MODE_SHARE = 'ShareLock';

protected function tearDown(): void
{
$this->closeAllPostgresPdoConnections();
Expand Down Expand Up @@ -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'
Expand All @@ -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,
],
);

Expand All @@ -147,7 +135,7 @@ private function findAllPostgresAdvisoryLocks(): array
);
$statement->execute(
[
'mode' => self::MODE_EXCLUSIVE,
'mode' => PostgresLockModeEnum::Exclusive->value,
],
);

Expand Down
Loading
Loading