Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -153,4 +153,4 @@ END
| Timeout: `positive-int` | ✅<br>(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.
75 changes: 19 additions & 56 deletions src/Utilities/PostgresTryLockLoopEmulator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand All @@ -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]),
);
}

/**
Expand All @@ -89,26 +47,31 @@ public function sql(int $timeout, bool $forTransaction): string
$suffix = $forTransaction ? '_xact' : '';

$sql = <<<EOD
DO $$
CREATE OR REPLACE FUNCTION
pg_temp.laravel_pg_try_advisory{$suffix}_lock_timeout(key text, timeout interval)
RETURNS boolean
AS $$
DECLARE
result boolean;
start timestamp with time zone;
now timestamp with time zone;
BEGIN
start := clock_timestamp();
LOOP
SELECT pg_try_advisory{$suffix}_lock(hashtext(?)) INTO result;
SELECT pg_try_advisory{$suffix}_lock(hashtext(key)) INTO result;
IF result THEN
RAISE 'LaravelDatabaseAdvisoryLock: Lock acquired successfully';
RETURN true;
END IF;
now := clock_timestamp();
IF now - start > 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);
Expand Down
24 changes: 24 additions & 0 deletions tests/SessionLockerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
27 changes: 27 additions & 0 deletions tests/TransactionLockerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down