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
66 changes: 47 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ Advisory Locking Features of Postgres/MySQL on Laravel

## Requirements

| Package | Version | Mandatory |
|:---|:------------------------------------|:---:|
| PHP | <code>^8.0.2</code> | ✅ |
| Laravel | <code>^8.0 &#124;&#124; ^9.0 &#124;&#124; ^10.0</code> | |
| PHPStan | <code>&gt;=1.1</code> | |
| Package | Version | Mandatory |
|:--------|:-------------------------------------------------------|:---------:|
| PHP | <code>^8.0.2</code> | ✅ |
| Laravel | <code>^8.0 &#124;&#124; ^9.0 &#124;&#124; ^10.0</code> | |
| PHPStan | <code>&gt;=1.1</code> | |

## Installing

```
composer require mpyw/laravel-database-advisory-lock:^4.0
composer require mpyw/laravel-database-advisory-lock:^4.1
```

## Basic usage
Expand Down Expand Up @@ -45,29 +45,37 @@ return [
use Illuminate\Support\Facades\DB;
use Illuminate\Database\ConnectionInterface;

// Postgres/MySQL
// Postgres/MySQL: Session-Level Locking (no wait)
$result = DB::advisoryLocker()
->forSession()
->withLocking('<key>', function (ConnectionInterface $conn) {
// critical section here
return ...;
});

// Postgres only feature
// Postgres only feature: Transaction-Level Locking (no wait)
$result = DB::transaction(function (ConnectionInterface $conn) {
$conn->advisoryLocker()->forTransaction()->lockOrFail('<key>');

// critical section here
return ...;
});

// MySQL only feature
// MySQL only feature: Session-Level Locking with timeout (waits for 5 seconds or fails)
$result = DB::advisoryLocker()
->forSession()
->withLocking('<key>', function (ConnectionInterface $conn) {
// critical section here
return ...;
}, timeout: 5);

// Postgres/MySQL: Session-Level Locking with infinite wait
$result = DB::advisoryLocker()
->forSession()
->withLocking('<key>', function (ConnectionInterface $conn) {
// critical section here
return ...;
}, timeout: -1);
```

## Advanced Usage
Expand Down Expand Up @@ -112,20 +120,40 @@ class PostgresConnection extends BasePostgresConnection

### Key Hashing Algorithm

```sql
-- Postgres: int8
hashtext('<key>')
```

```sql
-- MySQL: varchar(64)
CASE WHEN CHAR_LENGTH('<key>') > 64
THEN CONCAT(SUBSTR('<key>', 1, 24), SHA1('<key>'))
ELSE '<key>'
END
```

- Postgres advisory locking functions only accepts integer keys. So the driver converts key strings into 64-bit integers through `hashtext()` function.
- MySQL advisory locking functions accepts string keys but their length are limited within 64 bytes. When key strings exceed 64 bytes limit, the driver takes first 24 bytes from them and appends 40 bytes `sha1()` hashes.
- MySQL advisory locking functions accepts string keys but their length are limited within 64 chars. When key strings exceed 64 chars limit, the driver takes first 24 chars from them and appends 40 chars `sha1()` hashes.
- With either hashing algorithm, collisions can theoretically occur with very low probability.

### Transaction-Level Locks
### Locking Methods

| | Postgres | MySQL |
|:--------------------------|:---------|:------|
| Session-Level Locking | ✅ | ✅ |
| Transaction-Level Locking | ✅ | ❌ |

- MySQL does not support native transaction-level advisory locking.
- Postgres supports native transaction-level advisory locking.
- Locks can be acquired at any transaction scope.
- Session-Level locks can be acquired anywhere.
- They can be released manually or automatically through a destructor.
- For Postgres, there was a problem where the automatic lock release algorithm did not work properly, but this has been fixed in version 4.0.0. See [#2](https://github.com/mpyw/laravel-database-advisory-lock/pull/2) for details.
- Transaction-Level locks can be acquired within a transaction.
- You do not need to and cannot manually release locks that have been acquired.

### Session-Level Locks
### Timeout Values

- MySQL supports session-level advisory locking.
- An optional wait timeout can be set.
- Postgres supports session-level advisory locking.
- There was a problem where the automatic lock release algorithm did not work properly, but this has been fixed in version 4.0.0. See [#2](https://github.com/mpyw/laravel-database-advisory-lock/pull/2) for details.
| | Postgres | MySQL |
|:-------------------------------------------|:---------|:------|
| Timeout: `0` (default; immediate, no wait) | ✅ | ✅ |
| Timeout: `positive-int` | ❌ | ✅ |
| Timeout: `negative-int` (infinite wait) | ✅ | ✅ |
14 changes: 12 additions & 2 deletions src/Contracts/LockFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Mpyw\LaravelDatabaseAdvisoryLock\Contracts;

use Illuminate\Database\QueryException;
use ReflectionMethod;
use RuntimeException;

/**
Expand All @@ -14,8 +15,17 @@
*/
class LockFailedException extends QueryException
{
public function __construct(string $message, string $sql, array $bindings)
public function __construct(string $connectionName, string $message, string $sql, array $bindings)
{
parent::__construct($sql, $bindings, new RuntimeException($message));
$previous = new RuntimeException($message);

// Laravel 10 newly introduces $connectionName parameter
// https://github.com/laravel/framework/pull/43190
$args = (new ReflectionMethod(parent::class, __FUNCTION__))->getNumberOfParameters() > 3
? [$connectionName, $sql, $bindings, $previous]
: [$sql, $bindings, $previous];

// @phpstan-ignore-next-line
parent::__construct(...$args);
}
}
6 changes: 3 additions & 3 deletions src/MySqlSessionLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ public function __construct(
public function release(): bool
{
if (!$this->released) {
// When key strings exceed 64 bytes limit,
// it takes first 24 bytes from them and appends 40 bytes `sha1()` hashes.
$sql = 'SELECT RELEASE_LOCK(CASE WHEN LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END)';
// When key strings exceed 64 chars limit,
// 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));
Expand Down
13 changes: 9 additions & 4 deletions src/MySqlSessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,21 @@ public function __construct(

public function lockOrFail(string $key, int $timeout = 0): SessionLock
{
// When key strings exceed 64 bytes limit,
// it takes first 24 bytes from them and appends 40 bytes `sha1()` hashes.
$sql = "SELECT GET_LOCK(CASE WHEN LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, {$timeout})";
// When key strings exceed 64 chars limit,
// it takes first 24 chars from them and appends 40 chars `sha1()` hashes.
$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);

if (!$result) {
throw new LockFailedException("Failed to acquire lock: {$key}", $sql, $bindings);
throw new LockFailedException(
(string)$this->connection->getName(),
"Failed to acquire lock: {$key}",
$sql,
$bindings,
);
}

// Register the lock when it succeeds.
Expand Down
20 changes: 12 additions & 8 deletions src/PostgresSessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,23 @@ public function __construct(
*/
public function lockOrFail(string $key, int $timeout = 0): SessionLock
{
if ($timeout !== 0) {
// @codeCoverageIgnoreStart
throw new UnsupportedDriverException('Timeout feature is not supported');
// @codeCoverageIgnoreEnd
}

$sql = 'SELECT pg_try_advisory_lock(hashtext(?))';
// 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'),
};

$result = (new Selector($this->connection))
->selectBool($sql, [$key]);

if (!$result) {
throw new LockFailedException("Failed to acquire lock: {$key}", $sql, [$key]);
throw new LockFailedException(
(string)$this->connection->getName(),
"Failed to acquire lock: {$key}",
$sql,
[$key],
);
}

// Register the lock when it succeeds.
Expand Down
20 changes: 12 additions & 8 deletions src/PostgresTransactionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,23 @@ public function lockOrFail(string $key, int $timeout = 0): void
throw new InvalidTransactionLevelException('There are no transactions');
}

if ($timeout !== 0) {
// @codeCoverageIgnoreStart
throw new UnsupportedDriverException('Timeout feature is not supported');
// @codeCoverageIgnoreEnd
}

$sql = 'SELECT pg_try_advisory_xact_lock(hashtext(?))';
// 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 (!$result) {
throw new LockFailedException("Failed to acquire lock: {$key}", $sql, [$key]);
throw new LockFailedException(
(string)$this->connection->getName(),
"Failed to acquire lock: {$key}",
$sql,
[$key],
);
}
}
}
48 changes: 48 additions & 0 deletions tests/AcquiresLockInSeparateProcesses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Mpyw\LaravelDatabaseAdvisoryLock\Tests;

use Symfony\Component\Process\Process;

trait AcquiresLockInSeparateProcesses
{
private static function lockMysqlAsync(string $key, int $sleep): Process
{
$host = config('database.connections.mysql.host');
assert(is_string($host));

// == is intentionally used instead of ===.
// up to PHP 8.0, emulation mode on MySQL affects whether the return type is stringified or not.
$proc = new Process([PHP_BINARY, '-r',
<<<EOD
\$pdo = new PDO('mysql:host={$host};dbname=testing', 'testing', 'testing');
\$result = \$pdo->query("SELECT GET_LOCK('{$key}', 0)")->fetchColumn();
sleep({$sleep});
exit(\$result == 1 ? 0 : 1);
EOD,
]);
$proc->start();

return $proc;
}

private static function lockPostgresAsync(string $key, int $sleep): Process
{
$host = config('database.connections.pgsql.host');
assert(is_string($host));

$proc = new Process([PHP_BINARY, '-r',
<<<EOD
\$pdo = new PDO('pgsql:host={$host};dbname=testing', 'testing', 'testing');
\$result = \$pdo->query("SELECT pg_try_advisory_lock(hashtext('{$key}'))")->fetchColumn();
sleep({$sleep});
exit(\$result ? 0 : 1);
EOD,
]);
$proc->start();

return $proc;
}
}
6 changes: 3 additions & 3 deletions tests/ReconnectionToleranceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ public function testReconnectionWithoutActiveLocks(): void

// Retries
$this->assertSame([
'SELECT GET_LOCK(CASE WHEN LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)',
'SELECT GET_LOCK(CASE WHEN LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)',
'SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)',
'SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)',
], $this->queries);
}

Expand All @@ -115,7 +115,7 @@ public function testReconnectionWithActiveLocks(): void

// No retries
$this->assertSame([
'SELECT GET_LOCK(CASE WHEN LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)',
'SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)',
], $this->queries);
}
}
Loading