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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
run: composer phpstan

- name: Test
run: composer test -- --coverage-clover build/logs/clover.xml
run: composer test -- --testdox --coverage-clover build/logs/clover.xml
env:
PG_HOST: 127.0.0.1
MY_HOST: 127.0.0.1
Expand Down
6 changes: 6 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"require": {
"php": "^8.0.2",
"ext-pdo": "*",
"illuminate/events": "^8.0 || ^9.0 || ^10.0",
"illuminate/support": "^8.0 || ^9.0 || ^10.0",
"illuminate/database": "^8.0 || ^9.0 || ^10.0",
"illuminate/contracts": "^8.0 || ^9.0 || ^10.0"
Expand All @@ -46,6 +47,11 @@
"minimum-stability": "dev",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Mpyw\\LaravelDatabaseAdvisoryLock\\AdvisoryLockServiceProvider"
]
},
"phpstan": {
"includes": [
"extension.neon"
Expand Down
25 changes: 25 additions & 0 deletions src/AdvisoryLockServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Mpyw\LaravelDatabaseAdvisoryLock;

use Illuminate\Support\ServiceProvider;

/**
* class AdvisoryLockServiceProvider
*
* Automatically registered through package discovery.
*/
final class AdvisoryLockServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(TransactionEventHub::class);
}

public function boot(TransactionEventHub $hub): void
{
TransactionEventHub::setResolver(fn () => $hub);
}
}
12 changes: 11 additions & 1 deletion src/AdvisoryLocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@
use Illuminate\Database\QueryException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockerFactory as FactoryContract;

/**
* trait AdvisoryLocks
*
* Designed to be mixed in with the Connection classes.
*/
trait AdvisoryLocks
{
public ?FactoryContract $advisoryLocker = null;

/**
* Create LockerFactory or return existing one.
*/
public function advisoryLocker(): FactoryContract
{
assert($this instanceof Connection);
Expand All @@ -21,6 +29,8 @@ public function advisoryLocker(): FactoryContract
}

/**
* Overrides the original implementation.
*
* @param string $query
* @param array $bindings
* @throws QueryException
Expand All @@ -29,7 +39,7 @@ protected function handleQueryException(QueryException $e, $query, $bindings, Cl
{
assert($this instanceof Connection);

// Don't try again if there are session-level locks
// Don't try again if there are session-level locks.
if ($this->transactionLevel() > 0 || $this->advisoryLocker()->forSession()->hasAny()) {
throw $e;
}
Expand Down
3 changes: 0 additions & 3 deletions src/Concerns/ReleasesWhenDestructed.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@

namespace Mpyw\LaravelDatabaseAdvisoryLock\Concerns;

/**
* @internal
*/
trait ReleasesWhenDestructed
{
abstract public function release(): bool;
Expand Down
14 changes: 0 additions & 14 deletions src/Concerns/SessionLocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,10 @@
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock;

/**
* @internal
*/
trait SessionLocks
{
abstract public function lockOrFail(string $key, int $timeout = 0): SessionLock;

public function withLocking(string $key, callable $callback, int $timeout = 0): mixed
{
$lock = $this->lockOrFail($key, $timeout);

try {
return $callback();
} finally {
$lock->release();
}
}

public function tryLock(string $key, int $timeout = 0): ?SessionLock
{
try {
Expand Down
3 changes: 0 additions & 3 deletions src/Concerns/TransactionalLocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@

use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;

/**
* @internal
*/
trait TransactionalLocks
{
public function tryLock(string $key, int $timeout = 0): bool
Expand Down
5 changes: 5 additions & 0 deletions src/Contracts/InvalidTransactionLevelException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

use BadMethodCallException;

/**
* class InvalidTransactionLevelException
*
* You can't use TransactionLocker outside of transaction.
*/
class InvalidTransactionLevelException extends BadMethodCallException
{
}
5 changes: 5 additions & 0 deletions src/Contracts/LockFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
use Illuminate\Database\QueryException;
use RuntimeException;

/**
* class LockFailedException
*
* Lock acquisition has been failed.
*/
class LockFailedException extends QueryException
{
public function __construct(string $message, string $sql, array $bindings)
Expand Down
12 changes: 12 additions & 0 deletions src/Contracts/LockerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@

namespace Mpyw\LaravelDatabaseAdvisoryLock\Contracts;

/**
* interface LockerFactory
*
* Entrypoint used from the mix-in AdvisoryLocks trait.
* Underlying locker instances are managed as singletons.
*/
interface LockerFactory
{
/**
* Create a transaction-level locker or return existing one.
*/
public function forTransaction(): TransactionLocker;

/**
* Create a session-level locker or return existing one.
*/
public function forSession(): SessionLocker;
}
13 changes: 13 additions & 0 deletions src/Contracts/SessionLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@

use Illuminate\Database\QueryException;

/**
* interface SessionLock
*
* Acquired through SessionLocker.
*/
interface SessionLock
{
/**
* Explicitly releases the lock.
* If successful, nothing happens the second time or later.
* QueryException may be thrown on connection-level errors.
*
* @throws QueryException
*/
public function release(): bool;

/**
* Implicitly releases the lock on the object destruction.
* If it has already been explicitly released by release(), nothing will happen.
* QueryException may be thrown on connection-level errors.
*
* @throws QueryException
*/
public function __destruct();
Expand Down
25 changes: 23 additions & 2 deletions src/Contracts/SessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,55 @@

namespace Mpyw\LaravelDatabaseAdvisoryLock\Contracts;

use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\QueryException;

/**
* interface SessionLocker
*
* Session-level locker.
* Acquired locks must be explicitly released or connection must be disconnected.
*/
interface SessionLocker
{
/**
* Invoke $callback under the acquired lock then release it.
* QueryException may be thrown on connection-level errors.
*
* @phpstan-template T
* @phpstan-param callable(): T $callback
* @phpstan-param callable(ConnectionInterface): T $callback
* @phpstan-return T
*
* @psalm-template T
* @psalm-param callable(): T $callback
* @psalm-param callable(ConnectionInterface): T $callback
* @psalm-return T
*
* @param int $timeout Time to wait before acquiring a lock. This is NOT the expiry of the lock.
*
* @throws LockFailedException
* @throws QueryException
*/
public function withLocking(string $key, callable $callback, int $timeout = 0): mixed;

/**
* Attempts to acquire a lock or returns NULL if failed.
* QueryException may be thrown on connection-level errors.
*
* @throws QueryException
*/
public function tryLock(string $key, int $timeout = 0): ?SessionLock;

/**
* Attempts to acquire a lock or throw LockFailedException if failed.
* QueryException may be thrown on connection-level errors.
*
* @throws LockFailedException
* @throws QueryException
*/
public function lockOrFail(string $key, int $timeout = 0): SessionLock;

/**
* Indicates whether any session-level lock remains.
*/
public function hasAny(): bool;
}
12 changes: 12 additions & 0 deletions src/Contracts/TransactionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,26 @@

use Illuminate\Database\QueryException;

/**
* interface TransactionLocker
*
* Transaction-level locker.
* Acquired locks are implicitly released on transaction commits/rollbacks.
*/
interface TransactionLocker
{
/**
* Attempts to acquire a lock or returns false if failed.
* QueryException may be thrown on connection-level errors.
*
* @throws QueryException
*/
public function tryLock(string $key, int $timeout = 0): bool;

/**
* Attempts to acquire a lock or throw LockFailedException if failed.
* QueryException may be thrown on connection-level errors.
*
* @throws LockFailedException
* @throws QueryException
*/
Expand Down
22 changes: 22 additions & 0 deletions src/Contracts/TransactionTerminationListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Mpyw\LaravelDatabaseAdvisoryLock\Contracts;

use Illuminate\Database\Events\TransactionCommitted;
use Illuminate\Database\Events\TransactionRolledBack;

/**
* interface TransactionTerminationListener
*
* Some drivers can't release session-level locks immediately when an error occurs within a transaction.
* This listener is used for releasing after the transaction is terminated or rewinding to a savepoint.
*/
interface TransactionTerminationListener
{
/**
* A listener function.
*/
public function onTransactionTerminated(TransactionCommitted|TransactionRolledBack $event): void;
}
5 changes: 5 additions & 0 deletions src/Contracts/UnsupportedDriverException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

use DomainException;

/**
* class UnsupportedDriverException
*
* Requested operation is not supported on the driver.
*/
class UnsupportedDriverException extends DomainException
{
}
9 changes: 5 additions & 4 deletions src/MySqlSessionLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ 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)';

$this->released = (new Selector($this->connection))
->selectBool($sql, array_fill(0, 4, $this->key), false);
->selectBool($sql, array_fill(0, 4, $this->key));

if ($this->released) {
$this->locks->offsetUnset($this);
}
// Clean up the lock when it succeeds.
$this->released && $this->locks->offsetUnset($this);
}

return $this->released;
Expand Down
16 changes: 15 additions & 1 deletion src/MySqlSessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,36 @@ 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})";
$bindings = array_fill(0, 4, $key);

$result = (new Selector($this->connection))
->selectBool($sql, $bindings, false);
->selectBool($sql, $bindings);

if (!$result) {
throw new LockFailedException("Failed to acquire lock: {$key}", $sql, $bindings);
}

// Register the lock when it succeeds.
$lock = new MySqlSessionLock($this->connection, $this->locks, $key);
$this->locks[$lock] = true;

return $lock;
}

public function withLocking(string $key, callable $callback, int $timeout = 0): mixed
{
$lock = $this->lockOrFail($key, $timeout);

try {
return $callback($this->connection);
} finally {
$lock->release();
}
}

public function hasAny(): bool
{
return $this->locks->count() > 0;
Expand Down
Loading