Skip to content

Commit c8511f4

Browse files
authored
Rewrite logic to two keyed lock (#5)
1 parent 313e83e commit c8511f4

10 files changed

+651
-228
lines changed

README.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99

1010
## Introduction
1111

12+
> WARNING! This library is currently under development and may not be stable. Use in your services at your own risk.
13+
1214
PHP application-level database locking mechanisms to implement concurrency control patterns.
1315

1416
Supported drivers:
1517

16-
- Postgres
18+
- Postgres[PostgreSQL Advisory Locks Documentation](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)
1719

1820
## Installation
1921

@@ -33,12 +35,16 @@ composer require cybercog/php-db-locker
3335
$dbConnection = new PDO($dsn, $username, $password);
3436

3537
$postgresLocker = new \Cog\DbLocker\Locker\PostgresAdvisoryLocker();
36-
$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromLockId(
37-
new LockId('user', '4'),
38-
);
38+
$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromKeyValue('user', '4');
3939

4040
$dbConnection->beginTransaction();
41-
$isLockAcquired = $postgresLocker->acquireLockWithinTransaction($dbConnection, $postgresLockId);
41+
$isLockAcquired = $postgresLocker->acquireLock(
42+
$dbConnection,
43+
$postgresLockId,
44+
\Cog\DbLocker\Locker\PostgresAdvisoryLockScopeEnum::Transaction,
45+
\Cog\DbLocker\Locker\PostgresAdvisoryLockTypeEnum::NonBlocking,
46+
\Cog\DbLocker\Locker\PostgresLockModeEnum::Exclusive,
47+
);
4248
if ($isLockAcquired) {
4349
// Execute logic if lock was successful
4450
} else {
@@ -53,11 +59,15 @@ $dbConnection->commit();
5359
$dbConnection = new PDO($dsn, $username, $password);
5460

5561
$postgresLocker = new \Cog\DbLocker\Locker\PostgresAdvisoryLocker();
56-
$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromLockId(
57-
new LockId('user', '4'),
62+
$postgresLockId = \Cog\DbLocker\LockId\PostgresLockId::fromKeyValue('user', '4');
63+
64+
$isLockAcquired = $postgresLocker->acquireLock(
65+
$dbConnection,
66+
$postgresLockId,
67+
\Cog\DbLocker\Locker\PostgresAdvisoryLockScopeEnum::Session,
68+
\Cog\DbLocker\Locker\PostgresAdvisoryLockTypeEnum::NonBlocking,
69+
\Cog\DbLocker\Locker\PostgresLockModeEnum::Exclusive,
5870
);
59-
60-
$isLockAcquired = $postgresLocker->acquireLock($dbConnection, $postgresLockId);
6171
if ($isLockAcquired) {
6272
// Execute logic if lock was successful
6373
} else {

src/LockId/PostgresLockId.php

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,57 @@
1717

1818
final class PostgresLockId
1919
{
20-
private const DB_INT64_VALUE_MIN = -9_223_372_036_854_775_808;
21-
private const DB_INT64_VALUE_MAX = 9_223_372_036_854_775_807;
20+
private const DB_INT32_VALUE_MIN = -2_147_483_648;
2221
private const DB_INT32_VALUE_MAX = 2_147_483_647;
2322

24-
public function __construct(
25-
public readonly int $id,
23+
private function __construct(
24+
public readonly int $classId,
25+
public readonly int $objectId,
2626
public readonly string $humanReadableValue = '',
2727
) {
28-
if ($id < self::DB_INT64_VALUE_MIN) {
29-
throw new InvalidArgumentException('Out of bound exception (id is too small)');
28+
if ($classId < self::DB_INT32_VALUE_MIN) {
29+
throw new InvalidArgumentException("Out of bound exception (classId=$classId is too small)");
3030
}
31-
if ($id > self::DB_INT64_VALUE_MAX) {
32-
throw new InvalidArgumentException('Out of bound exception (id is too big)');
31+
if ($classId > self::DB_INT32_VALUE_MAX) {
32+
throw new InvalidArgumentException("Out of bound exception (classId=$classId is too big)");
3333
}
34+
if ($objectId < self::DB_INT32_VALUE_MIN) {
35+
throw new InvalidArgumentException("Out of bound exception (objectId=$objectId is too small)");
36+
}
37+
if ($objectId > self::DB_INT32_VALUE_MAX) {
38+
throw new InvalidArgumentException("Out of bound exception (objectId=$objectId is too big)");
39+
}
40+
}
41+
42+
public static function fromKeyValue(
43+
string $key,
44+
string $value = '',
45+
): self {
46+
return self::fromLockId(
47+
new LockId(
48+
$key,
49+
$value,
50+
),
51+
);
3452
}
3553

3654
public static function fromLockId(
3755
LockId $lockId,
3856
): self {
39-
$lockStringId = (string)$lockId;
57+
return new self(
58+
classId: self::convertStringToSignedInt32($lockId->key),
59+
objectId: self::convertStringToSignedInt32($lockId->value),
60+
humanReadableValue: (string)$lockId,
61+
);
62+
}
4063

64+
public static function fromIntKeys(
65+
int $classId,
66+
int $objectId,
67+
): self {
4168
return new self(
42-
id: self::convertStringToSignedInt32($lockStringId),
43-
humanReadableValue: $lockStringId,
69+
$classId,
70+
$objectId,
4471
);
4572
}
4673

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cog\DbLocker\Locker;
6+
7+
/**
8+
* PostgresAdvisoryLockScopeEnum defines the scope of advisory lock acquisition.
9+
*
10+
* - Session: Session-level advisory lock (PG_ADVISORY_LOCK, PG_TRY_ADVISORY_LOCK)
11+
* - Transaction: Transaction-level advisory lock (PG_ADVISORY_XACT_LOCK, PG_TRY_ADVISORY_XACT_LOCK)
12+
*/
13+
enum PostgresAdvisoryLockScopeEnum
14+
{
15+
case Session;
16+
case Transaction;
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cog\DbLocker\Locker;
6+
7+
/**
8+
* PostgresAdvisoryLockTypeEnum defines the type of advisory lock acquisition.
9+
*
10+
* - NonBlocking: Attempt to acquire the lock without blocking (PG_TRY_ADVISORY_LOCK, PG_TRY_ADVISORY_XACT_LOCK).
11+
* - Blocking: Acquire the lock, blocking until it becomes available (PG_ADVISORY_LOCK, PG_ADVISORY_XACT_LOCK).
12+
*/
13+
enum PostgresAdvisoryLockTypeEnum
14+
{
15+
case NonBlocking;
16+
case Blocking;
17+
}

src/Locker/PostgresAdvisoryLocker.php

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,73 +19,115 @@
1919

2020
final class PostgresAdvisoryLocker
2121
{
22-
public function tryAcquireLock(
22+
/**
23+
* Acquire an advisory lock with configurable scope, mode and behavior.
24+
*/
25+
public function acquireLock(
2326
PDO $dbConnection,
2427
PostgresLockId $postgresLockId,
28+
PostgresAdvisoryLockScopeEnum $scope = PostgresAdvisoryLockScopeEnum::Transaction,
29+
PostgresAdvisoryLockTypeEnum $type = PostgresAdvisoryLockTypeEnum::NonBlocking,
30+
PostgresLockModeEnum $mode = PostgresLockModeEnum::Exclusive,
2531
): bool {
26-
// TODO: Need to sanitize humanReadableValue?
27-
$statement = $dbConnection->prepare(
28-
<<<SQL
29-
SELECT PG_TRY_ADVISORY_LOCK(:lock_id); -- $postgresLockId->humanReadableValue
30-
SQL,
31-
);
32-
$statement->execute(
33-
[
34-
'lock_id' => $postgresLockId->id,
35-
],
36-
);
37-
38-
return $statement->fetchColumn(0);
39-
}
40-
41-
public function tryAcquireLockWithinTransaction(
42-
PDO $dbConnection,
43-
PostgresLockId $postgresLockId,
44-
): bool {
45-
if ($dbConnection->inTransaction() === false) {
46-
$lockId = $postgresLockId->humanReadableValue;
47-
32+
if ($scope === PostgresAdvisoryLockScopeEnum::Transaction && $dbConnection->inTransaction() === false) {
4833
throw new LogicException(
49-
"Transaction-level advisory lock `$lockId` cannot be acquired outside of transaction",
34+
"Transaction-level advisory lock `$postgresLockId->humanReadableValue` cannot be acquired outside of transaction",
5035
);
5136
}
5237

53-
// TODO: Need to sanitize humanReadableValue?
54-
$statement = $dbConnection->prepare(
55-
<<<SQL
56-
SELECT PG_TRY_ADVISORY_XACT_LOCK(:lock_id); -- $postgresLockId->humanReadableValue
57-
SQL,
58-
);
38+
$sql = match ([$scope, $type, $mode]) {
39+
[
40+
PostgresAdvisoryLockScopeEnum::Transaction,
41+
PostgresAdvisoryLockTypeEnum::NonBlocking,
42+
PostgresLockModeEnum::Exclusive,
43+
] => 'SELECT PG_TRY_ADVISORY_XACT_LOCK(:class_id, :object_id);',
44+
[
45+
PostgresAdvisoryLockScopeEnum::Transaction,
46+
PostgresAdvisoryLockTypeEnum::Blocking,
47+
PostgresLockModeEnum::Exclusive,
48+
] => 'SELECT PG_ADVISORY_XACT_LOCK(:class_id, :object_id);',
49+
[
50+
PostgresAdvisoryLockScopeEnum::Transaction,
51+
PostgresAdvisoryLockTypeEnum::NonBlocking,
52+
PostgresLockModeEnum::Share,
53+
] => 'SELECT PG_TRY_ADVISORY_XACT_LOCK_SHARE(:class_id, :object_id);',
54+
[
55+
PostgresAdvisoryLockScopeEnum::Transaction,
56+
PostgresAdvisoryLockTypeEnum::Blocking,
57+
PostgresLockModeEnum::Share,
58+
] => 'SELECT PG_ADVISORY_XACT_LOCK_SHARE(:class_id, :object_id);',
59+
[
60+
PostgresAdvisoryLockScopeEnum::Session,
61+
PostgresAdvisoryLockTypeEnum::NonBlocking,
62+
PostgresLockModeEnum::Exclusive,
63+
] => 'SELECT PG_TRY_ADVISORY_LOCK(:class_id, :object_id);',
64+
[
65+
PostgresAdvisoryLockScopeEnum::Session,
66+
PostgresAdvisoryLockTypeEnum::Blocking,
67+
PostgresLockModeEnum::Exclusive,
68+
] => 'SELECT PG_ADVISORY_LOCK(:class_id, :object_id);',
69+
[
70+
PostgresAdvisoryLockScopeEnum::Session,
71+
PostgresAdvisoryLockTypeEnum::NonBlocking,
72+
PostgresLockModeEnum::Share,
73+
] => 'SELECT PG_TRY_ADVISORY_LOCK_SHARE(:class_id, :object_id);',
74+
[
75+
PostgresAdvisoryLockScopeEnum::Session,
76+
PostgresAdvisoryLockTypeEnum::Blocking,
77+
PostgresLockModeEnum::Share,
78+
] => 'SELECT PG_ADVISORY_LOCK_SHARE(:class_id, :object_id);',
79+
};
80+
$sql .= " -- $postgresLockId->humanReadableValue";
81+
82+
$statement = $dbConnection->prepare($sql);
5983
$statement->execute(
6084
[
61-
'lock_id' => $postgresLockId->id,
85+
'class_id' => $postgresLockId->classId,
86+
'object_id' => $postgresLockId->objectId,
6287
],
6388
);
6489

6590
return $statement->fetchColumn(0);
6691
}
6792

93+
/**
94+
* Release session level advisory lock.
95+
*/
6896
public function releaseLock(
6997
PDO $dbConnection,
7098
PostgresLockId $postgresLockId,
99+
PostgresAdvisoryLockScopeEnum $scope = PostgresAdvisoryLockScopeEnum::Session,
71100
): bool {
101+
if ($scope === PostgresAdvisoryLockScopeEnum::Transaction) {
102+
throw new \InvalidArgumentException('Transaction-level advisory lock cannot be released');
103+
}
104+
72105
$statement = $dbConnection->prepare(
73106
<<<SQL
74-
SELECT PG_ADVISORY_UNLOCK(:lock_id); -- $postgresLockId->humanReadableValue
107+
SELECT PG_ADVISORY_UNLOCK(:class_id, :object_id); -- $postgresLockId->humanReadableValue
75108
SQL,
76109
);
77110
$statement->execute(
78111
[
79-
'lock_id' => $postgresLockId->id,
112+
'class_id' => $postgresLockId->classId,
113+
'object_id' => $postgresLockId->objectId,
80114
],
81115
);
82116

83117
return $statement->fetchColumn(0);
84118
}
85119

120+
/**
121+
* Release all session level advisory locks held by the current session.
122+
*/
86123
public function releaseAllLocks(
87124
PDO $dbConnection,
125+
PostgresAdvisoryLockScopeEnum $scope = PostgresAdvisoryLockScopeEnum::Session,
88126
): void {
127+
if ($scope === PostgresAdvisoryLockScopeEnum::Transaction) {
128+
throw new \InvalidArgumentException('Transaction-level advisory lock cannot be released');
129+
}
130+
89131
$statement = $dbConnection->prepare(
90132
<<<'SQL'
91133
SELECT PG_ADVISORY_UNLOCK_ALL();

src/Locker/PostgresLockModeEnum.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cog\DbLocker\Locker;
6+
7+
/**
8+
* PostgresLockModeEnum defines the access mode of advisory lock acquisition.
9+
*/
10+
enum PostgresLockModeEnum: string
11+
{
12+
case Exclusive = 'ExclusiveLock';
13+
case Share = 'ShareLock';
14+
}

test/Integration/AbstractIntegrationTestCase.php

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@
1313

1414
namespace Cog\Test\DbLocker\Integration;
1515

16+
use Cog\DbLocker\Locker\PostgresLockModeEnum;
1617
use Cog\DbLocker\LockId\PostgresLockId;
1718
use PDO;
1819
use PHPUnit\Framework\TestCase;
1920

2021
abstract class AbstractIntegrationTestCase extends TestCase
2122
{
22-
private const MODE_EXCLUSIVE = 'ExclusiveLock';
23-
private const MODE_SHARE = 'ShareLock';
24-
2523
protected function tearDown(): void
2624
{
2725
$this->closeAllPostgresPdoConnections();
@@ -91,16 +89,6 @@ private function findPostgresAdvisoryLockInConnection(
9189
): object | null {
9290
// For one-argument advisory locks, Postgres stores the signed 64-bit key as two 32-bit integers:
9391
// classid = high 32 bits, objid = low 32 bits.
94-
$lockClassId = ($postgresLockId->id >> 32) & 0xFFFFFFFF;
95-
$lockObjectId = $postgresLockId->id & 0xFFFFFFFF;
96-
97-
// Convert to signed 32-bit if necessary (Postgres stores as signed)
98-
if ($lockClassId > 0x7FFFFFFF) {
99-
$lockClassId -= 0x100000000;
100-
}
101-
if ($lockObjectId > 0x7FFFFFFF) {
102-
$lockObjectId -= 0x100000000;
103-
}
10492

10593
$statement = $dbConnection->prepare(
10694
<<<'SQL'
@@ -116,11 +104,11 @@ private function findPostgresAdvisoryLockInConnection(
116104
);
117105
$statement->execute(
118106
[
119-
'lock_class_id' => $lockClassId,
120-
'lock_object_id' => $lockObjectId,
121-
'lock_object_subid' => 1, // For one keyed value
107+
'lock_class_id' => $postgresLockId->classId,
108+
'lock_object_id' => $postgresLockId->objectId,
109+
'lock_object_subid' => 2, // Using two keyed locks
122110
'connection_pid' => $dbConnection->pgsqlGetPid(),
123-
'mode' => self::MODE_EXCLUSIVE,
111+
'mode' => PostgresLockModeEnum::Exclusive->value,
124112
],
125113
);
126114

@@ -147,7 +135,7 @@ private function findAllPostgresAdvisoryLocks(): array
147135
);
148136
$statement->execute(
149137
[
150-
'mode' => self::MODE_EXCLUSIVE,
138+
'mode' => PostgresLockModeEnum::Exclusive->value,
151139
],
152140
);
153141

0 commit comments

Comments
 (0)