diff --git a/README.md b/README.md
index fa5cf28..15cb6b7 100644
--- a/README.md
+++ b/README.md
@@ -139,10 +139,10 @@ END
### Locking Methods
-| | Postgres | MySQL |
-|:--------------------------|:---------|:------|
-| Session-Level Locking | ✅ | ✅ |
-| Transaction-Level Locking | ✅ | ❌ |
+| | Postgres | MySQL |
+|:--------------------------|:---------:|:------:|
+| Session-Level Locking | ✅ | ✅ |
+| Transaction-Level Locking | ✅ | ❌ |
- Session-Level locks can be acquired anywhere.
- They can be released manually or automatically through a destructor.
@@ -152,8 +152,10 @@ END
### Timeout Values
-| | Postgres | MySQL |
-|:-------------------------------------------|:---------|:------|
-| Timeout: `0` (default; immediate, no wait) | ✅ | ✅ |
-| Timeout: `positive-int` | ❌ | ✅ |
-| Timeout: `negative-int` (infinite wait) | ✅ | ✅ |
+| | Postgres | MySQL |
+|:-------------------------------------------|:----------------:|:------:|
+| Timeout: `0` (default; immediate, no wait) | ✅ | ✅ |
+| Timeout: `positive-int` | ✅
(Emulated) | ✅ |
+| Timeout: `negative-int` (infinite wait) | ✅ | ✅ |
+
+- Postgres does not natively support waiting for a finite specific amount of time, but this is emulated by looping through an anonymous procedure.
diff --git a/composer.json b/composer.json
index 372206e..59b0bf0 100644
--- a/composer.json
+++ b/composer.json
@@ -40,7 +40,7 @@
},
"scripts": {
"test": "vendor/bin/phpunit",
- "phpstan": "vendor/bin/phpstan analyse --level=9 src tests phpstan",
+ "phpstan": "vendor/bin/phpstan analyse --level=9 --memory-limit=2G src tests phpstan",
"cs": "vendor/bin/php-cs-fixer fix --dry-run",
"cs:fix": "vendor/bin/php-cs-fixer fix"
},
diff --git a/phpstan.neon b/phpstan.neon
index 67f3f74..1aee6d0 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -8,5 +8,5 @@ parameters:
paths:
- src/AdvisoryLocks.php
- src/Contracts/LockFailedException.php
- - src/Selector.php
+ - src/Utilities/Selector.php
- tests/*.php
diff --git a/src/MySqlSessionLock.php b/src/MySqlSessionLock.php
index cc1fb31..2b8f90a 100644
--- a/src/MySqlSessionLock.php
+++ b/src/MySqlSessionLock.php
@@ -7,6 +7,7 @@
use Illuminate\Database\MySqlConnection;
use Mpyw\LaravelDatabaseAdvisoryLock\Concerns\ReleasesWhenDestructed;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;
use WeakMap;
use function array_fill;
@@ -34,8 +35,8 @@ public function release(): bool
// it takes first 24 chars from them and appends 40 chars `sha1()` hashes.
$sql = 'SELECT RELEASE_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END)';
- $this->released = (new Selector($this->connection))
- ->selectBool($sql, array_fill(0, 4, $this->key));
+ $this->released = (bool)(new Selector($this->connection))
+ ->select($sql, array_fill(0, 4, $this->key));
// Clean up the lock when it succeeds.
$this->released && $this->locks->offsetUnset($this);
diff --git a/src/MySqlSessionLocker.php b/src/MySqlSessionLocker.php
index ca4b091..68503a9 100644
--- a/src/MySqlSessionLocker.php
+++ b/src/MySqlSessionLocker.php
@@ -9,6 +9,7 @@
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLocker;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;
use WeakMap;
use function array_fill;
@@ -35,8 +36,8 @@ public function lockOrFail(string $key, int $timeout = 0): SessionLock
$sql = "SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, {$timeout})";
$bindings = array_fill(0, 4, $key);
- $result = (new Selector($this->connection))
- ->selectBool($sql, $bindings);
+ $result = (bool)(new Selector($this->connection))
+ ->select($sql, $bindings);
if (!$result) {
throw new LockFailedException(
diff --git a/src/PostgresSessionLock.php b/src/PostgresSessionLock.php
index eee5746..96c51b1 100644
--- a/src/PostgresSessionLock.php
+++ b/src/PostgresSessionLock.php
@@ -10,6 +10,7 @@
use Mpyw\LaravelDatabaseAdvisoryLock\Concerns\ReleasesWhenDestructed;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\TransactionTerminationListener;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;
use PDOException;
use WeakMap;
@@ -36,8 +37,8 @@ public function release(): bool
{
if (!$this->released) {
try {
- $this->released = (new Selector($this->connection))
- ->selectBool('SELECT pg_advisory_unlock(hashtext(?))', [$this->key]);
+ $this->released = (bool)(new Selector($this->connection))
+ ->select('SELECT pg_advisory_unlock(hashtext(?))', [$this->key]);
} catch (PDOException $e) {
// Postgres can't release session-level locks immediately
// when an error occurs within a transaction.
diff --git a/src/PostgresSessionLocker.php b/src/PostgresSessionLocker.php
index dee34fd..a846fdf 100644
--- a/src/PostgresSessionLocker.php
+++ b/src/PostgresSessionLocker.php
@@ -9,7 +9,8 @@
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLocker;
-use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\UnsupportedDriverException;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\PostgresTryLockLoopEmulator;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;
use WeakMap;
final class PostgresSessionLocker implements SessionLocker
@@ -34,15 +35,21 @@ public function __construct(
*/
public function lockOrFail(string $key, int $timeout = 0): SessionLock
{
- // Negative timeout means infinite wait
- $sql = match ($timeout <=> 0) {
- -1 => "SELECT pg_advisory_lock(hashtext(?))::text = ''",
- 0 => 'SELECT pg_try_advisory_lock(hashtext(?))',
- 1 => throw new UnsupportedDriverException('Positive timeout is not supported'),
- };
+ if ($timeout > 0) {
+ // Positive timeout can be emulated through repeating sleep and retry
+ $emulator = new PostgresTryLockLoopEmulator($this->connection);
+ $sql = $emulator->sql($timeout, false);
+ $result = $emulator->performTryLockLoop($key, $timeout);
+ } else {
+ // Negative timeout means infinite wait
+ // Zero timeout means no wait
+ $sql = $timeout < 0
+ ? "SELECT pg_advisory_lock(hashtext(?))::text = ''"
+ : 'SELECT pg_try_advisory_lock(hashtext(?))';
- $result = (new Selector($this->connection))
- ->selectBool($sql, [$key]);
+ $selector = new Selector($this->connection);
+ $result = (bool)$selector->select($sql, [$key]);
+ }
if (!$result) {
throw new LockFailedException(
diff --git a/src/PostgresTransactionLocker.php b/src/PostgresTransactionLocker.php
index 19b78f8..78c2d58 100644
--- a/src/PostgresTransactionLocker.php
+++ b/src/PostgresTransactionLocker.php
@@ -9,7 +9,8 @@
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\InvalidTransactionLevelException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\TransactionLocker;
-use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\UnsupportedDriverException;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\PostgresTryLockLoopEmulator;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;
final class PostgresTransactionLocker implements TransactionLocker
{
@@ -26,15 +27,21 @@ public function lockOrFail(string $key, int $timeout = 0): void
throw new InvalidTransactionLevelException('There are no transactions');
}
- // Negative timeout means infinite wait
- $sql = match ($timeout <=> 0) {
- -1 => "SELECT pg_advisory_xact_lock(hashtext(?))::text = ''",
- 0 => 'SELECT pg_try_advisory_xact_lock(hashtext(?))',
- 1 => throw new UnsupportedDriverException('Positive timeout is not supported'),
- };
-
- $result = (new Selector($this->connection))
- ->selectBool($sql, [$key]);
+ if ($timeout > 0) {
+ // Positive timeout can be emulated through repeating sleep and retry
+ $emulator = new PostgresTryLockLoopEmulator($this->connection);
+ $sql = $emulator->sql($timeout, false);
+ $result = $emulator->performTryLockLoop($key, $timeout, true);
+ } else {
+ // Negative timeout means infinite wait
+ // Zero timeout means no wait
+ $sql = $timeout < 0
+ ? "SELECT pg_advisory_xact_lock(hashtext(?))::text = ''"
+ : 'SELECT pg_try_advisory_xact_lock(hashtext(?))';
+
+ $selector = new Selector($this->connection);
+ $result = (bool)$selector->select($sql, [$key]);
+ }
if (!$result) {
throw new LockFailedException(
diff --git a/src/Utilities/PDOStatementEmulator.php b/src/Utilities/PDOStatementEmulator.php
new file mode 100644
index 0000000..1099961
--- /dev/null
+++ b/src/Utilities/PDOStatementEmulator.php
@@ -0,0 +1,32 @@
+getAttribute(PDO::ATTR_EMULATE_PREPARES);
+ $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
+
+ try {
+ return $callback();
+ } finally {
+ $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, $original);
+ }
+ }
+}
diff --git a/src/Utilities/PostgresTryLockLoopEmulator.php b/src/Utilities/PostgresTryLockLoopEmulator.php
new file mode 100644
index 0000000..f440bcb
--- /dev/null
+++ b/src/Utilities/PostgresTryLockLoopEmulator.php
@@ -0,0 +1,116 @@
+connection = $connection;
+ }
+
+ /**
+ * Perform a time-limited lock acquisition.
+ *
+ * @phpstan-param positive-int $timeout
+ * @throws QueryException
+ */
+ public function performTryLockLoop(string $key, int $timeout, bool $forTransaction = false): bool
+ {
+ try {
+ // Binding parameters to procedures is only allowed when PDOStatement emulation is enabled.
+ PDOStatementEmulator::emulated(
+ $this->connection->getPdo(),
+ fn () => $this->performRawTryLockLoop($key, $timeout, $forTransaction),
+ );
+ // @codeCoverageIgnoreStart
+ throw new LogicException('Unreachable here');
+ // @codeCoverageIgnoreEnd
+ } catch (QueryException $e) {
+ // Handle user level exceptions
+ if ($e->getCode() === 'P0001') {
+ $prefix = 'ERROR: LaravelDatabaseAdvisoryLock';
+ $message = (string)($e->errorInfo[2] ?? '');
+ if (str_starts_with($message, "{$prefix}: Lock acquired successfully")) {
+ return true;
+ }
+ if (str_starts_with($message, "{$prefix}: Lock timeout")) {
+ return false;
+ }
+ }
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Generates SQL to emulate time-limited lock acquisition.
+ * This query will always throw QueryException.
+ *
+ * @phpstan-param positive-int $timeout
+ * @throws QueryException
+ */
+ public function performRawTryLockLoop(string $key, int $timeout, bool $forTransaction): void
+ {
+ $this->connection->select($this->sql($timeout, $forTransaction), [$key]);
+ }
+
+ /**
+ * Generates SQL to emulate time-limited lock acquisition.
+ *
+ * @phpstan-param positive-int $timeout
+ */
+ public function sql(int $timeout, bool $forTransaction): string
+ {
+ $suffix = $forTransaction ? '_xact' : '';
+
+ $sql = << interval '{$timeout} seconds' THEN
+ RAISE 'LaravelDatabaseAdvisoryLock: Lock timeout';
+ END IF;
+ PERFORM pg_sleep(0.5);
+ END LOOP;
+ END
+ $$;
+ EOD;
+
+ return (string)preg_replace('/\s++/', ' ', $sql);
+ }
+}
diff --git a/src/Selector.php b/src/Utilities/Selector.php
similarity index 58%
rename from src/Selector.php
rename to src/Utilities/Selector.php
index 965b807..c0cc6d3 100644
--- a/src/Selector.php
+++ b/src/Utilities/Selector.php
@@ -2,11 +2,13 @@
declare(strict_types=1);
-namespace Mpyw\LaravelDatabaseAdvisoryLock;
+namespace Mpyw\LaravelDatabaseAdvisoryLock\Utilities;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\QueryException;
+use function array_shift;
+
/**
* class Selector
*
@@ -22,19 +24,18 @@ public function __construct(
}
/**
- * Run query to get a boolean from the result.
- * Illegal values are regarded as false.
+ * Run query to get a single value from the result.
* QueryException may be thrown on connection-level errors.
*
* @throws QueryException
*/
- public function selectBool(string $sql, array $bindings): bool
+ public function select(string $sql, array $bindings): mixed
{
// Always pass false to $useReadPdo
- return (bool)current(
- (array)$this
- ->connection
- ->selectOne($sql, $bindings, false),
- );
+ $row = (array)$this
+ ->connection
+ ->selectOne($sql, $bindings, false);
+
+ return array_shift($row);
}
}
diff --git a/tests/AcquiresLockInSeparateProcesses.php b/tests/AcquiresLockInSeparateProcesses.php
index 01c7e84..ef16d10 100644
--- a/tests/AcquiresLockInSeparateProcesses.php
+++ b/tests/AcquiresLockInSeparateProcesses.php
@@ -4,10 +4,23 @@
namespace Mpyw\LaravelDatabaseAdvisoryLock\Tests;
+use LogicException;
use Symfony\Component\Process\Process;
trait AcquiresLockInSeparateProcesses
{
+ private static function lockAsync(string $driver, string $key, int $sleep): Process
+ {
+ if ($driver === 'mysql') {
+ return self::lockMysqlAsync($key, $sleep);
+ }
+ if ($driver === 'pgsql') {
+ return self::lockPostgresAsync($key, $sleep);
+ }
+
+ throw new LogicException('Unsupported driver');
+ }
+
private static function lockMysqlAsync(string $key, int $sleep): Process
{
$host = config('database.connections.mysql.host');
diff --git a/tests/SessionLockerTest.php b/tests/SessionLockerTest.php
index b229f34..a4bf0cf 100644
--- a/tests/SessionLockerTest.php
+++ b/tests/SessionLockerTest.php
@@ -7,8 +7,7 @@
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
-use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\UnsupportedDriverException;
-use Mpyw\LaravelDatabaseAdvisoryLock\Selector;
+use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;
class SessionLockerTest extends TestCase
{
@@ -118,8 +117,8 @@ public function testMysqlHashing(): void
->forSession()
->withLocking($key, function (ConnectionInterface $conn) use ($key, &$passed): void {
$this->assertTrue(
- (new Selector($conn))
- ->selectBool(
+ (bool)(new Selector($conn))
+ ->select(
'SELECT IS_USED_LOCK(?)',
[substr($key, 0, 64 - 40) . sha1($key)],
),
@@ -140,8 +139,8 @@ public function testMysqlHashingMultibyte(): void
->forSession()
->withLocking($key, function (ConnectionInterface $conn) use ($key, &$passed): void {
$this->assertTrue(
- (new Selector($conn))
- ->selectBool(
+ (bool)(new Selector($conn))
+ ->select(
'SELECT IS_USED_LOCK(?)',
[mb_substr($key, 0, 64 - 40) . sha1($key)],
),
@@ -152,98 +151,64 @@ public function testMysqlHashingMultibyte(): void
$this->assertTrue($passed);
}
- public function testFiniteMysqlTimeoutSuccess(): void
+ /**
+ * @dataProvider connections
+ */
+ public function testFiniteTimeoutSuccess(string $name): void
{
- $passed = false;
-
- $proc = self::lockMysqlAsync('foo', 2);
+ $proc = self::lockAsync($name, 'foo', 2);
sleep(1);
try {
- DB::connection('mysql')
+ $result = DB::connection($name)
->advisoryLocker()
->forSession()
- ->withLocking('foo', function () use (&$passed): void {
- $passed = true;
- }, 3);
+ ->tryLock('foo', 3);
$this->assertSame(0, $proc->wait());
- $this->assertTrue($passed);
- } finally {
- $proc->wait();
- }
- }
-
- public function testFiniteMysqlTimeoutExceeded(): void
- {
- $proc = self::lockMysqlAsync('foo', 3);
- sleep(1);
-
- try {
- $this->expectException(LockFailedException::class);
- $this->expectExceptionMessage('Failed to acquire lock: foo');
-
- DB::connection('mysql')
- ->advisoryLocker()
- ->forSession()
- ->withLocking('foo', function (): void {
- }, 1);
+ $this->assertNotNull($result);
} finally {
$proc->wait();
}
}
- public function testInfiniteMysqlTimeoutSuccess(): void
+ /**
+ * @dataProvider connections
+ */
+ public function testFiniteTimeoutExceeded(string $name): void
{
- $passed = false;
-
- $proc = self::lockMysqlAsync('foo', 2);
+ $proc = self::lockAsync($name, 'foo', 3);
sleep(1);
try {
- DB::connection('mysql')
+ $result = DB::connection($name)
->advisoryLocker()
->forSession()
- ->withLocking('foo', function () use (&$passed): void {
- $passed = true;
- }, -1);
+ ->tryLock('foo', 1);
$this->assertSame(0, $proc->wait());
- $this->assertTrue($passed);
+ $this->assertNull($result);
} finally {
$proc->wait();
}
}
- public function testFinitePostgresTimeoutInvalid(): void
- {
- $this->expectException(UnsupportedDriverException::class);
- $this->expectExceptionMessage('Positive timeout is not supported');
-
- DB::connection('pgsql')
- ->advisoryLocker()
- ->forSession()
- ->withLocking('foo', function (): void {
- }, 1);
- }
-
- public function testInfinitePostgresTimeoutSuccess(): void
+ /**
+ * @dataProvider connections
+ */
+ public function testInfiniteTimeoutSuccess(string $name): void
{
- $passed = false;
-
- $proc = self::lockPostgresAsync('foo', 2);
+ $proc = self::lockAsync($name, 'foo', 2);
sleep(1);
try {
- DB::connection('pgsql')
+ $result = DB::connection($name)
->advisoryLocker()
->forSession()
- ->withLocking('foo', function () use (&$passed): void {
- $passed = true;
- }, -1);
+ ->tryLock('foo', -1);
$this->assertSame(0, $proc->wait());
- $this->assertTrue($passed);
+ $this->assertNotNull($result);
} finally {
$proc->wait();
}
diff --git a/tests/TransactionLockerTest.php b/tests/TransactionLockerTest.php
index 89c6d95..0f27171 100644
--- a/tests/TransactionLockerTest.php
+++ b/tests/TransactionLockerTest.php
@@ -8,7 +8,6 @@
use Illuminate\Support\Facades\DB;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\InvalidTransactionLevelException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
-use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\UnsupportedDriverException;
use Throwable;
class TransactionLockerTest extends TestCase
@@ -141,14 +140,41 @@ public function testWithoutTransactions(string $name): void
/**
* @throws Throwable
*/
- public function testFinitePostgresTimeoutInvalid(): void
+ public function testFinitePostgresTimeoutSuccess(): void
{
- $this->expectException(UnsupportedDriverException::class);
- $this->expectExceptionMessage('Positive timeout is not supported');
+ $proc = self::lockPostgresAsync('foo', 2);
+ sleep(1);
- DB::connection('pgsql')->transaction(function (ConnectionInterface $conn): void {
- $conn->advisoryLocker()->forTransaction()->lockOrFail('foo', 1);
- });
+ try {
+ $result = DB::connection('pgsql')->transaction(function (ConnectionInterface $conn) {
+ return $conn->advisoryLocker()->forTransaction()->tryLock('foo', 3);
+ });
+
+ $this->assertSame(0, $proc->wait());
+ $this->assertTrue($result);
+ } finally {
+ $proc->wait();
+ }
+ }
+
+ /**
+ * @throws Throwable
+ */
+ public function testFinitePostgresTimeoutExceeded(): void
+ {
+ $proc = self::lockPostgresAsync('foo', 3);
+ sleep(1);
+
+ try {
+ $result = DB::connection('pgsql')->transaction(function (ConnectionInterface $conn) {
+ return $conn->advisoryLocker()->forTransaction()->tryLock('foo', 1);
+ });
+
+ $this->assertSame(0, $proc->wait());
+ $this->assertFalse($result);
+ } finally {
+ $proc->wait();
+ }
}
/**