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
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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` | ✅<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.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ parameters:
paths:
- src/AdvisoryLocks.php
- src/Contracts/LockFailedException.php
- src/Selector.php
- src/Utilities/Selector.php
- tests/*.php
5 changes: 3 additions & 2 deletions src/MySqlSessionLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/MySqlSessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/PostgresSessionLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
Expand Down
25 changes: 16 additions & 9 deletions src/PostgresSessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
27 changes: 17 additions & 10 deletions src/PostgresTransactionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions src/Utilities/PDOStatementEmulator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Mpyw\LaravelDatabaseAdvisoryLock\Utilities;

use PDO;

/**
* class Emulator
*
* @internal
*/
final class PDOStatementEmulator
{
/**
* @phpstan-template T
* @phpstan-param callable(): T $callback
* @phpstan-return T
*/
public static function emulated(PDO $pdo, callable $callback): mixed
{
$original = $pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);

try {
return $callback();
} finally {
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, $original);
}
}
}
116 changes: 116 additions & 0 deletions src/Utilities/PostgresTryLockLoopEmulator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace Mpyw\LaravelDatabaseAdvisoryLock\Utilities;

use Illuminate\Database\Connection;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\QueryException;
use LogicException;

use function preg_replace;
use function str_starts_with;

/**
* class PostgresTryLockLoopEmulator
*
* @internal
*/
final class PostgresTryLockLoopEmulator
{
private Connection $connection;

public function __construct(
ConnectionInterface $connection,
) {
if (!$connection instanceof Connection) {
// @codeCoverageIgnoreStart
throw new LogicException('Procedure features are not available.');
// @codeCoverageIgnoreEnd
}

$this->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 = <<<EOD
DO $$
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;
IF result THEN
RAISE 'LaravelDatabaseAdvisoryLock: Lock acquired successfully';
END IF;
now := clock_timestamp();
IF now - start > 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);
}
}
19 changes: 10 additions & 9 deletions src/Selector.php → src/Utilities/Selector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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);
}
}
Loading