diff --git a/README.md b/README.md index 0fb3bb6..26d9260 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Advisory Locking Features of Postgres/MySQL on Laravel ## Installing ``` -composer require mpyw/laravel-database-advisory-lock:^4.2 +composer require mpyw/laravel-database-advisory-lock:^4.2.1 ``` ## Basic usage @@ -153,4 +153,4 @@ END | 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. +- Postgres does not natively support waiting for a finite specific amount of time, but this is emulated by looping through a temporary function. diff --git a/src/Utilities/PostgresTryLockLoopEmulator.php b/src/Utilities/PostgresTryLockLoopEmulator.php index f440bcb..16346a5 100644 --- a/src/Utilities/PostgresTryLockLoopEmulator.php +++ b/src/Utilities/PostgresTryLockLoopEmulator.php @@ -4,13 +4,10 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock\Utilities; -use Illuminate\Database\Connection; -use Illuminate\Database\ConnectionInterface; +use Illuminate\Database\PostgresConnection; use Illuminate\Database\QueryException; -use LogicException; use function preg_replace; -use function str_starts_with; /** * class PostgresTryLockLoopEmulator @@ -19,18 +16,9 @@ */ final class PostgresTryLockLoopEmulator { - private Connection $connection; - public function __construct( - ConnectionInterface $connection, + private PostgresConnection $connection, ) { - if (!$connection instanceof Connection) { - // @codeCoverageIgnoreStart - throw new LogicException('Procedure features are not available.'); - // @codeCoverageIgnoreEnd - } - - $this->connection = $connection; } /** @@ -41,42 +29,12 @@ public function __construct( */ 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]); + // Binding parameters to procedures is only allowed when PDOStatement emulation is enabled. + return PDOStatementEmulator::emulated( + $this->connection->getPdo(), + fn () => (bool)(new Selector($this->connection)) + ->select($this->sql($timeout, $forTransaction), [$key]), + ); } /** @@ -89,7 +47,10 @@ public function sql(int $timeout, bool $forTransaction): string $suffix = $forTransaction ? '_xact' : ''; $sql = << interval '{$timeout} seconds' THEN - RAISE 'LaravelDatabaseAdvisoryLock: Lock timeout'; + IF now - start > timeout THEN + RETURN false; END IF; PERFORM pg_sleep(0.5); END LOOP; END - $$; + $$ + LANGUAGE plpgsql; + SELECT pg_temp.laravel_pg_try_advisory{$suffix}_lock_timeout(?, interval '{$timeout} seconds'); EOD; return (string)preg_replace('/\s++/', ' ', $sql); diff --git a/tests/SessionLockerTest.php b/tests/SessionLockerTest.php index a4bf0cf..e8cb3c9 100644 --- a/tests/SessionLockerTest.php +++ b/tests/SessionLockerTest.php @@ -172,6 +172,30 @@ public function testFiniteTimeoutSuccess(string $name): void } } + public function testFinitePostgresTimeoutSuccessConsecutive(): void + { + $proc1 = self::lockPostgresAsync('foo', 5); + $proc2 = self::lockPostgresAsync('baz', 5); + sleep(1); + + try { + $conn = DB::connection('pgsql'); + $results = [ + $conn->advisoryLocker()->forSession()->tryLock('foo', 1), + $conn->advisoryLocker()->forSession()->tryLock('bar', 1), + $conn->advisoryLocker()->forSession()->tryLock('baz', 1), + $conn->advisoryLocker()->forSession()->tryLock('qux', 1), + ]; + $result_booleans = array_map(fn ($result) => $result !== null, $results); + $this->assertSame(0, $proc1->wait()); + $this->assertSame(0, $proc2->wait()); + $this->assertSame([false, true, false, true], $result_booleans); + } finally { + $proc1->wait(); + $proc2->wait(); + } + } + /** * @dataProvider connections */ diff --git a/tests/TransactionLockerTest.php b/tests/TransactionLockerTest.php index 0f27171..3b62d0c 100644 --- a/tests/TransactionLockerTest.php +++ b/tests/TransactionLockerTest.php @@ -157,6 +157,33 @@ public function testFinitePostgresTimeoutSuccess(): void } } + /** + * @throws Throwable + */ + public function testFinitePostgresTimeoutSuccessConsecutive(): void + { + $proc1 = self::lockPostgresAsync('foo', 5); + $proc2 = self::lockPostgresAsync('baz', 5); + sleep(1); + + try { + $result = DB::connection('pgsql')->transaction(function (ConnectionInterface $conn) { + return [ + $conn->advisoryLocker()->forTransaction()->tryLock('foo', 1), + $conn->advisoryLocker()->forTransaction()->tryLock('bar', 1), + $conn->advisoryLocker()->forTransaction()->tryLock('baz', 1), + $conn->advisoryLocker()->forTransaction()->tryLock('qux', 1), + ]; + }); + $this->assertSame(0, $proc1->wait()); + $this->assertSame(0, $proc2->wait()); + $this->assertSame([false, true, false, true], $result); + } finally { + $proc1->wait(); + $proc2->wait(); + } + } + /** * @throws Throwable */