From a4338a15007d6cb3f233cf2ac9ad87c78058fcb1 Mon Sep 17 00:00:00 2001 From: mpyw Date: Thu, 14 Jul 2022 01:32:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Accept=20ConnectionI?= =?UTF-8?q?nterface=20on=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Concerns/SessionLocks.php | 11 ----------- src/Contracts/SessionLocker.php | 5 +++-- src/MySqlSessionLocker.php | 11 +++++++++++ src/PostgresSessionLocker.php | 11 +++++++++++ 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/Concerns/SessionLocks.php b/src/Concerns/SessionLocks.php index 19cafdd..e117054 100644 --- a/src/Concerns/SessionLocks.php +++ b/src/Concerns/SessionLocks.php @@ -14,17 +14,6 @@ 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 { diff --git a/src/Contracts/SessionLocker.php b/src/Contracts/SessionLocker.php index 31b9f5b..e1eb55f 100644 --- a/src/Contracts/SessionLocker.php +++ b/src/Contracts/SessionLocker.php @@ -4,17 +4,18 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock\Contracts; +use Illuminate\Database\ConnectionInterface; use Illuminate\Database\QueryException; interface SessionLocker { /** * @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 * * @throws LockFailedException diff --git a/src/MySqlSessionLocker.php b/src/MySqlSessionLocker.php index 96523f2..01b6309 100644 --- a/src/MySqlSessionLocker.php +++ b/src/MySqlSessionLocker.php @@ -46,6 +46,17 @@ public function lockOrFail(string $key, int $timeout = 0): SessionLock 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; diff --git a/src/PostgresSessionLocker.php b/src/PostgresSessionLocker.php index e597744..60aa5ad 100644 --- a/src/PostgresSessionLocker.php +++ b/src/PostgresSessionLocker.php @@ -50,6 +50,17 @@ public function lockOrFail(string $key, int $timeout = 0): SessionLock 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; From 50461075d2084cee540e379511265144c5fd42ff Mon Sep 17 00:00:00 2001 From: mpyw Date: Thu, 14 Jul 2022 14:50:18 +0900 Subject: [PATCH 2/2] wip --- .github/workflows/ci.yml | 2 +- composer.json | 6 + src/AdvisoryLockServiceProvider.php | 25 +++ src/AdvisoryLocks.php | 12 +- src/Concerns/ReleasesWhenDestructed.php | 3 - src/Concerns/SessionLocks.php | 3 - src/Concerns/TransactionalLocks.php | 3 - .../InvalidTransactionLevelException.php | 5 + src/Contracts/LockFailedException.php | 5 + src/Contracts/LockerFactory.php | 12 ++ src/Contracts/SessionLock.php | 13 ++ src/Contracts/SessionLocker.php | 20 ++ src/Contracts/TransactionLocker.php | 12 ++ .../TransactionTerminationListener.php | 22 +++ src/Contracts/UnsupportedDriverException.php | 5 + src/MySqlSessionLock.php | 9 +- src/MySqlSessionLocker.php | 5 +- src/PostgresSessionLock.php | 41 +++- src/PostgresSessionLocker.php | 15 +- src/PostgresTransactionLocker.php | 2 +- src/Selector.php | 13 +- src/TransactionEventHub.php | 114 +++++++++++ tests/ReconnectionToleranceTest.php | 5 +- tests/SessionLockerTest.php | 28 ++- tests/TableTestCase.php | 23 +++ tests/TestCase.php | 6 +- tests/TransactionErrorRecoveryTest.php | 165 ++++++++++++++++ ...actionErrorRefreshDatabaseRecoveryTest.php | 178 ++++++++++++++++++ tests/TransactionLockerTest.php | 29 +-- 29 files changed, 722 insertions(+), 59 deletions(-) create mode 100644 src/AdvisoryLockServiceProvider.php create mode 100644 src/Contracts/TransactionTerminationListener.php create mode 100644 src/TransactionEventHub.php create mode 100644 tests/TableTestCase.php create mode 100644 tests/TransactionErrorRecoveryTest.php create mode 100644 tests/TransactionErrorRefreshDatabaseRecoveryTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67b68c1..cae47e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/composer.json b/composer.json index 5053278..372206e 100644 --- a/composer.json +++ b/composer.json @@ -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" @@ -46,6 +47,11 @@ "minimum-stability": "dev", "prefer-stable": true, "extra": { + "laravel": { + "providers": [ + "Mpyw\\LaravelDatabaseAdvisoryLock\\AdvisoryLockServiceProvider" + ] + }, "phpstan": { "includes": [ "extension.neon" diff --git a/src/AdvisoryLockServiceProvider.php b/src/AdvisoryLockServiceProvider.php new file mode 100644 index 0000000..976d9fa --- /dev/null +++ b/src/AdvisoryLockServiceProvider.php @@ -0,0 +1,25 @@ +app->singleton(TransactionEventHub::class); + } + + public function boot(TransactionEventHub $hub): void + { + TransactionEventHub::setResolver(fn () => $hub); + } +} diff --git a/src/AdvisoryLocks.php b/src/AdvisoryLocks.php index 8dada23..000a20d 100644 --- a/src/AdvisoryLocks.php +++ b/src/AdvisoryLocks.php @@ -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); @@ -21,6 +29,8 @@ public function advisoryLocker(): FactoryContract } /** + * Overrides the original implementation. + * * @param string $query * @param array $bindings * @throws QueryException @@ -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; } diff --git a/src/Concerns/ReleasesWhenDestructed.php b/src/Concerns/ReleasesWhenDestructed.php index a818a6d..8bd712b 100644 --- a/src/Concerns/ReleasesWhenDestructed.php +++ b/src/Concerns/ReleasesWhenDestructed.php @@ -4,9 +4,6 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock\Concerns; -/** - * @internal - */ trait ReleasesWhenDestructed { abstract public function release(): bool; diff --git a/src/Concerns/SessionLocks.php b/src/Concerns/SessionLocks.php index e117054..d5f6048 100644 --- a/src/Concerns/SessionLocks.php +++ b/src/Concerns/SessionLocks.php @@ -7,9 +7,6 @@ use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException; use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock; -/** - * @internal - */ trait SessionLocks { abstract public function lockOrFail(string $key, int $timeout = 0): SessionLock; diff --git a/src/Concerns/TransactionalLocks.php b/src/Concerns/TransactionalLocks.php index 46931b1..a3da396 100644 --- a/src/Concerns/TransactionalLocks.php +++ b/src/Concerns/TransactionalLocks.php @@ -6,9 +6,6 @@ use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException; -/** - * @internal - */ trait TransactionalLocks { public function tryLock(string $key, int $timeout = 0): bool diff --git a/src/Contracts/InvalidTransactionLevelException.php b/src/Contracts/InvalidTransactionLevelException.php index 9eab792..419a105 100644 --- a/src/Contracts/InvalidTransactionLevelException.php +++ b/src/Contracts/InvalidTransactionLevelException.php @@ -6,6 +6,11 @@ use BadMethodCallException; +/** + * class InvalidTransactionLevelException + * + * You can't use TransactionLocker outside of transaction. + */ class InvalidTransactionLevelException extends BadMethodCallException { } diff --git a/src/Contracts/LockFailedException.php b/src/Contracts/LockFailedException.php index 0dec738..72ce056 100644 --- a/src/Contracts/LockFailedException.php +++ b/src/Contracts/LockFailedException.php @@ -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) diff --git a/src/Contracts/LockerFactory.php b/src/Contracts/LockerFactory.php index f5ce295..302da76 100644 --- a/src/Contracts/LockerFactory.php +++ b/src/Contracts/LockerFactory.php @@ -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; } diff --git a/src/Contracts/SessionLock.php b/src/Contracts/SessionLock.php index 5c153e1..539e5df 100644 --- a/src/Contracts/SessionLock.php +++ b/src/Contracts/SessionLock.php @@ -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(); diff --git a/src/Contracts/SessionLocker.php b/src/Contracts/SessionLocker.php index e1eb55f..5a086cc 100644 --- a/src/Contracts/SessionLocker.php +++ b/src/Contracts/SessionLocker.php @@ -7,9 +7,18 @@ 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(ConnectionInterface): T $callback * @phpstan-return T @@ -18,21 +27,32 @@ interface SessionLocker * @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; } diff --git a/src/Contracts/TransactionLocker.php b/src/Contracts/TransactionLocker.php index 6cf6cbc..19e5992 100644 --- a/src/Contracts/TransactionLocker.php +++ b/src/Contracts/TransactionLocker.php @@ -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 */ diff --git a/src/Contracts/TransactionTerminationListener.php b/src/Contracts/TransactionTerminationListener.php new file mode 100644 index 0000000..1e276a2 --- /dev/null +++ b/src/Contracts/TransactionTerminationListener.php @@ -0,0 +1,22 @@ +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; diff --git a/src/MySqlSessionLocker.php b/src/MySqlSessionLocker.php index 01b6309..97f8402 100644 --- a/src/MySqlSessionLocker.php +++ b/src/MySqlSessionLocker.php @@ -30,16 +30,19 @@ 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; diff --git a/src/PostgresSessionLock.php b/src/PostgresSessionLock.php index 8fe77a6..eee5746 100644 --- a/src/PostgresSessionLock.php +++ b/src/PostgresSessionLock.php @@ -4,16 +4,21 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock; +use Illuminate\Database\Events\TransactionCommitted; +use Illuminate\Database\Events\TransactionRolledBack; use Illuminate\Database\PostgresConnection; use Mpyw\LaravelDatabaseAdvisoryLock\Concerns\ReleasesWhenDestructed; use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock; +use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\TransactionTerminationListener; +use PDOException; use WeakMap; -final class PostgresSessionLock implements SessionLock +final class PostgresSessionLock implements SessionLock, TransactionTerminationListener { use ReleasesWhenDestructed; private bool $released = false; + private ?TransactionEventHub $hub; /** * @param WeakMap $locks @@ -23,19 +28,43 @@ public function __construct( private WeakMap $locks, private string $key, ) { + $this->hub = TransactionEventHub::resolve(); + $this->hub?->initializeWithDispatcher($this->connection->getEventDispatcher()); } public function release(): bool { if (!$this->released) { - $this->released = (new Selector($this->connection)) - ->selectBool('SELECT pg_advisory_unlock(hashtext(?))', [$this->key], false); - - if ($this->released) { - $this->locks->offsetUnset($this); + try { + $this->released = (new Selector($this->connection)) + ->selectBool('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. + // Register onTransactionTerminated() for releasing + // after the transaction is terminated or rewinding to a savepoint. + self::causedByTransactionAbort($e) + ? $this->hub?->registerOnceListener($this) + : throw $e; } + + // Clean up the lock when it succeeds. + $this->released && $this->locks->offsetUnset($this); } return $this->released; } + + /** + * @see https://www.postgresql.org/docs/current/errcodes-appendix.html + */ + private static function causedByTransactionAbort(PDOException $e): bool + { + return $e->getCode() === '25P02'; + } + + public function onTransactionTerminated(TransactionCommitted|TransactionRolledBack $event): void + { + $this->release(); + } } diff --git a/src/PostgresSessionLocker.php b/src/PostgresSessionLocker.php index 60aa5ad..3730bc6 100644 --- a/src/PostgresSessionLocker.php +++ b/src/PostgresSessionLocker.php @@ -27,6 +27,11 @@ public function __construct( $this->locks = new WeakMap(); } + /** + * {@inheritDoc} + * + * Use of this method is strongly discouraged in Postgres. Use withLocking() instead. + */ public function lockOrFail(string $key, int $timeout = 0): SessionLock { if ($timeout !== 0) { @@ -38,12 +43,13 @@ public function lockOrFail(string $key, int $timeout = 0): SessionLock $sql = 'SELECT pg_try_advisory_lock(hashtext(?))'; $result = (new Selector($this->connection)) - ->selectBool($sql, [$key], false); + ->selectBool($sql, [$key]); if (!$result) { throw new LockFailedException("Failed to acquire lock: {$key}", $sql, [$key]); } + // Register the lock when it succeeds. $lock = new PostgresSessionLock($this->connection, $this->locks, $key); $this->locks[$lock] = true; @@ -55,7 +61,12 @@ public function withLocking(string $key, callable $callback, int $timeout = 0): $lock = $this->lockOrFail($key, $timeout); try { - return $callback($this->connection); + // In Postgres, savepoints allow recovery from errors. + // This ensures release() on finally. + /** @noinspection PhpUnhandledExceptionInspection */ + return $this->connection->transactionLevel() > 0 + ? $this->connection->transaction(fn () => $callback($this->connection)) + : $callback($this->connection); } finally { $lock->release(); } diff --git a/src/PostgresTransactionLocker.php b/src/PostgresTransactionLocker.php index 316bdf3..f240c8d 100644 --- a/src/PostgresTransactionLocker.php +++ b/src/PostgresTransactionLocker.php @@ -35,7 +35,7 @@ public function lockOrFail(string $key, int $timeout = 0): void $sql = 'SELECT pg_try_advisory_xact_lock(hashtext(?))'; $result = (new Selector($this->connection)) - ->selectBool($sql, [$key], false); + ->selectBool($sql, [$key]); if (!$result) { throw new LockFailedException("Failed to acquire lock: {$key}", $sql, [$key]); diff --git a/src/Selector.php b/src/Selector.php index edc2c4a..965b807 100644 --- a/src/Selector.php +++ b/src/Selector.php @@ -8,6 +8,10 @@ use Illuminate\Database\QueryException; /** + * class Selector + * + * Helper utilities to retrieve results. + * * @internal */ final class Selector @@ -18,14 +22,19 @@ public function __construct( } /** + * Run query to get a boolean from the result. + * Illegal values are regarded as false. + * QueryException may be thrown on connection-level errors. + * * @throws QueryException */ - public function selectBool(string $sql, array $bindings, bool $useReadPdo = true): bool + public function selectBool(string $sql, array $bindings): bool { + // Always pass false to $useReadPdo return (bool)current( (array)$this ->connection - ->selectOne($sql, $bindings, $useReadPdo), + ->selectOne($sql, $bindings, false), ); } } diff --git a/src/TransactionEventHub.php b/src/TransactionEventHub.php new file mode 100644 index 0000000..0b6aa27 --- /dev/null +++ b/src/TransactionEventHub.php @@ -0,0 +1,114 @@ +> + */ + private WeakMap $dispatchersAndListeners; + + /** + * @var null|callable(): self + */ + private static $resolver; + + /** + * Set a singleton instance resolver. + * + * @param null|callable(): self $resolver + */ + public static function setResolver(?callable $resolver): void + { + self::$resolver = $resolver; + } + + /** + * Create or retrieve a singleton instance through resolver. + */ + public static function resolve(): ?self + { + return self::$resolver ? (self::$resolver)() : null; + } + + public function __construct() + { + $this->dispatchersAndListeners = new WeakMap(); + } + + /** + * Register self::onTransactionTerminated() as a listener once per connection. + */ + public function initializeWithDispatcher(Dispatcher $dispatcher): void + { + if (!isset($this->dispatchersAndListeners[$dispatcher])) { + $dispatcher->listen( + [TransactionCommitted::class, TransactionRolledBack::class], + [self::class, 'onTransactionTerminated'], + ); + } + + $this->dispatchersAndListeners[$dispatcher] ??= []; + } + + /** + * Register underlying user listener per connection. + * Listeners registered here are invoked only once. + */ + public function registerOnceListener(TransactionTerminationListener $listener): void + { + foreach ($this->dispatchersAndListeners as $dispatcher => $_) { + $this->dispatchersAndListeners[$dispatcher][spl_object_hash($listener)] = $listener; + } + } + + /** + * Fire on events. + */ + public function onTransactionTerminated(TransactionCommitted|TransactionRolledBack $event): void + { + /** @var array> $savedListenerGroups */ + $savedListenerGroups = []; + + // First, save all listeners. + foreach ($this->dispatchersAndListeners as $dispatcher => $listeners) { + foreach ($listeners as $listener) { + $savedListenerGroups[spl_object_hash($dispatcher)][spl_object_hash($listener)] = $listener; + } + } + + // Next, remove listeners in advance. + foreach ($this->dispatchersAndListeners as $dispatcher => $_) { + $this->dispatchersAndListeners[$dispatcher] = []; + } + + // Finally, run the saved listeners. + // It does not matter if new listeners are registered again during the execution. + foreach ($savedListenerGroups as $savedListeners) { + foreach ($savedListeners as $listener) { + try { + $listener->onTransactionTerminated($event); + } catch (Throwable) { + } + } + } + } +} diff --git a/tests/ReconnectionToleranceTest.php b/tests/ReconnectionToleranceTest.php index 20d4301..6c14bfe 100644 --- a/tests/ReconnectionToleranceTest.php +++ b/tests/ReconnectionToleranceTest.php @@ -5,6 +5,7 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock\Tests; use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Events\StatementPrepared; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; @@ -98,12 +99,12 @@ public function testReconnectionWithActiveLocks(): void DB::connection('mysql') ->advisoryLocker() ->forSession() - ->withLocking('foo', function (): void { + ->withLocking('foo', function (ConnectionInterface $conn): void { $this->startListening(); try { // MySQL doesn't accept empty locks, so this will trigger QueryException - DB::connection('mysql') + $conn ->advisoryLocker() ->forSession() ->withLocking('', fn () => null); diff --git a/tests/SessionLockerTest.php b/tests/SessionLockerTest.php index 2246f67..825949f 100644 --- a/tests/SessionLockerTest.php +++ b/tests/SessionLockerTest.php @@ -4,6 +4,7 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock\Tests; +use Illuminate\Database\ConnectionInterface; use Illuminate\Support\Facades\DB; use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException; use Mpyw\LaravelDatabaseAdvisoryLock\Selector; @@ -70,8 +71,8 @@ public function testDifferentKeysOnSameConnections(string $name): void DB::connection($name) ->advisoryLocker() ->forSession() - ->withLocking('foo', function () use ($name, &$passed): void { - DB::connection($name) + ->withLocking('foo', function (ConnectionInterface $conn) use (&$passed): void { + $conn ->advisoryLocker() ->forSession() ->withLocking('bar', function () use (&$passed): void { @@ -92,8 +93,8 @@ public function testSameKeysOnSameConnections(string $name): void DB::connection($name) ->advisoryLocker() ->forSession() - ->withLocking('foo', function () use ($name, &$passed): void { - DB::connection($name) + ->withLocking('foo', function (ConnectionInterface $conn) use (&$passed): void { + $conn ->advisoryLocker() ->forSession() ->withLocking('foo', function () use (&$passed): void { @@ -104,16 +105,15 @@ public function testSameKeysOnSameConnections(string $name): void $this->assertTrue($passed); } - public function testMySqlTimeout(): void + public function testMysqlTimeout(): void { - $name = 'mysql'; $passed = false; - DB::connection($name) + DB::connection('mysql') ->advisoryLocker() ->forSession() - ->withLocking('foo', function () use ($name, &$passed): void { - DB::connection($name) + ->withLocking('foo', function (ConnectionInterface $conn) use (&$passed): void { + $conn ->advisoryLocker() ->forSession() ->withLocking('foo', function () use (&$passed): void { @@ -124,22 +124,20 @@ public function testMySqlTimeout(): void $this->assertTrue($passed); } - public function testMySqlHashing(): void + public function testMysqlHashing(): void { - $name = 'mysql'; $key = str_repeat('a', 65); $passed = false; - DB::connection($name) + DB::connection('mysql') ->advisoryLocker() ->forSession() - ->withLocking($key, function () use ($name, $key, &$passed): void { + ->withLocking($key, function (ConnectionInterface $conn) use ($key, &$passed): void { $this->assertTrue( - (new Selector(DB::connection($name))) + (new Selector($conn)) ->selectBool( 'SELECT IS_USED_LOCK(?)', [substr($key, 0, 64 - 40) . sha1($key)], - false, ), ); $passed = true; diff --git a/tests/TableTestCase.php b/tests/TableTestCase.php new file mode 100644 index 0000000..05c02dd --- /dev/null +++ b/tests/TableTestCase.php @@ -0,0 +1,23 @@ +dropIfExists('users'); + $schema->create('users', function (Blueprint $table): void { + $table->unsignedBigInteger('id')->unique(); + }); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index d8eb27a..caf93fb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,7 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock\Tests; +use Mpyw\LaravelDatabaseAdvisoryLock\AdvisoryLockServiceProvider; use Mpyw\LaravelDatabaseAdvisoryLock\ConnectionServiceProvider; use Orchestra\Testbench\TestCase as BaseTestCase; @@ -11,7 +12,10 @@ abstract class TestCase extends BaseTestCase { protected function getPackageProviders($app): array { - return [ConnectionServiceProvider::class]; + return [ + AdvisoryLockServiceProvider::class, + ConnectionServiceProvider::class, + ]; } protected function getEnvironmentSetUp($app): void diff --git a/tests/TransactionErrorRecoveryTest.php b/tests/TransactionErrorRecoveryTest.php new file mode 100644 index 0000000..1d4a734 --- /dev/null +++ b/tests/TransactionErrorRecoveryTest.php @@ -0,0 +1,165 @@ +enableQueryLog(); + + $conn + ->advisoryLocker() + ->forSession() + ->withLocking('foo', function (ConnectionInterface $conn) use (&$passed): void { + $this->assertSame(0, $conn->transactionLevel()); + $conn->insert('insert into users(id) values(1)'); + + try { + // The following statement triggers an error + $conn->insert('insert into users(id) values(1)'); + } catch (QueryException) { + } + // The following statement is valid because there are no transactions + $conn->insert('insert into users(id) values(2)'); + $passed = true; + }); + + $this->assertTrue($passed); + $this->assertSame([ + 'SELECT pg_try_advisory_lock(hashtext(?))', + 'insert into users(id) values(1)', + 'insert into users(id) values(2)', + 'SELECT pg_advisory_unlock(hashtext(?))', + ], array_column($conn->getQueryLog(), 'query')); + + $this->assertNotNull( + DB::connection('pgsql2') + ->advisoryLocker() + ->forSession() + ->tryLock('foo'), + ); + } + + /** + * @throws Throwable + */ + public function testWithLockingRollbacksToSavepoint(): void + { + $passed = false; + + $conn = DB::connection('pgsql'); + assert($conn instanceof Connection); + $conn->enableQueryLog(); + + $conn->transaction(function (ConnectionInterface $conn) use (&$passed): void { + $this->assertSame(1, $conn->transactionLevel()); + $conn->insert('insert into users(id) values(1)'); + + try { + $conn + ->advisoryLocker() + ->forSession() + ->withLocking('foo', function (ConnectionInterface $conn): void { + // The level is 2 because savepoint is automatically created + $this->assertSame(2, $conn->transactionLevel()); + + // The following statement triggers an error + $conn->insert('insert into users(id) values(1)'); + $this->fail(); + }); + // @phpstan-ignore-next-line + $this->fail(); + } catch (QueryException) { + } + // The following statement is valid because it is rolled back to the savepoint + $this->assertSame(1, $conn->transactionLevel()); + $conn->insert('insert into users(id) values(2)'); + $passed = true; + }); + + $this->assertTrue($passed); + $this->assertSame([ + 'insert into users(id) values(1)', + 'SELECT pg_try_advisory_lock(hashtext(?))', + 'SELECT pg_advisory_unlock(hashtext(?))', + 'insert into users(id) values(2)', + ], array_column($conn->getQueryLog(), 'query')); + + $this->assertNotNull( + DB::connection('pgsql2') + ->advisoryLocker() + ->forSession() + ->tryLock('foo'), + ); + } + + /** + * @throws Throwable + */ + public function testDestructorReleasesLocksAfterTransactionTerminated(): void + { + $conn = DB::connection('pgsql'); + assert($conn instanceof Connection); + $conn->enableQueryLog(); + + try { + $conn->transaction(function (ConnectionInterface $conn): void { + // lockOrFail() doesn't create any savepoints + $this->assertSame(1, $conn->transactionLevel()); + + /** @noinspection PhpUnusedLocalVariableInspection */ + $lock = $conn->advisoryLocker()->forSession()->lockOrFail('foo'); + $this->assertSame(1, $conn->transactionLevel()); + + $conn->insert('insert into users(id) values(1)'); + + try { + // The following statement triggers an error + $conn->insert('insert into users(id) values(1)'); + } catch (QueryException) { + } + // The following statement is invalid [*] + $conn->insert('insert into users(id) values(2)'); + $this->fail(); + }); + $this->fail(); + } catch (QueryException $e) { + // Thrown from [*] + $this->assertSame( + $e->getMessage(), + 'SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: ' + . 'current transaction is aborted, commands ignored until end of transaction block ' + . '(SQL: insert into users(id) values(2))', + ); + } + + $this->assertSame([ + 'SELECT pg_try_advisory_lock(hashtext(?))', + 'insert into users(id) values(1)', + 'SELECT pg_advisory_unlock(hashtext(?))', + ], array_column($conn->getQueryLog(), 'query')); + + $this->assertNotNull( + DB::connection('pgsql2') + ->advisoryLocker() + ->forSession() + ->tryLock('foo'), + ); + } +} diff --git a/tests/TransactionErrorRefreshDatabaseRecoveryTest.php b/tests/TransactionErrorRefreshDatabaseRecoveryTest.php new file mode 100644 index 0000000..24092a9 --- /dev/null +++ b/tests/TransactionErrorRefreshDatabaseRecoveryTest.php @@ -0,0 +1,178 @@ +enableQueryLog(); + + try { + $conn + ->advisoryLocker() + ->forSession() + ->withLocking('foo', function (ConnectionInterface $conn): void { + // RefreshDatabase affects to the transaction level + $this->assertSame(2, $conn->transactionLevel()); + $conn->insert('insert into users(id) values(1)'); + + try { + // The following statement triggers an error + $conn->insert('insert into users(id) values(1)'); + } catch (QueryException) { + } + // The following statement is invalid [*] + $conn->insert('insert into users(id) values(2)'); + }); + } catch (QueryException $e) { + // Thrown from [*] + $this->assertSame( + $e->getMessage(), + 'SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: ' + . 'current transaction is aborted, commands ignored until end of transaction block ' + . '(SQL: insert into users(id) values(2))', + ); + } + + $this->assertSame([ + 'SELECT pg_try_advisory_lock(hashtext(?))', + 'insert into users(id) values(1)', + 'SELECT pg_advisory_unlock(hashtext(?))', + ], array_column($conn->getQueryLog(), 'query')); + + $this->assertNotNull( + DB::connection('pgsql2') + ->advisoryLocker() + ->forSession() + ->tryLock('foo'), + ); + } + + /** + * @throws Throwable + */ + public function testWithLockingRollbacksToSavepoint(): void + { + $passed = false; + + $conn = DB::connection('pgsql'); + assert($conn instanceof Connection); + $conn->enableQueryLog(); + + $conn->transaction(function (ConnectionInterface $conn) use (&$passed): void { + // RefreshDatabase affects to the transaction level + $this->assertSame(2, $conn->transactionLevel()); + $conn->insert('insert into users(id) values(1)'); + + try { + $conn + ->advisoryLocker() + ->forSession() + ->withLocking('foo', function (ConnectionInterface $conn): void { + // The level is 3 because savepoint is automatically created + $this->assertSame(3, $conn->transactionLevel()); + + // The following statement triggers an error + $conn->insert('insert into users(id) values(1)'); + $this->fail(); + }); + // @phpstan-ignore-next-line + $this->fail(); + } catch (QueryException) { + } + // The following statement is valid because it is rolled back to the savepoint + $this->assertSame(2, $conn->transactionLevel()); + $conn->insert('insert into users(id) values(2)'); + $passed = true; + }); + + $this->assertTrue($passed); + $this->assertSame([ + 'insert into users(id) values(1)', + 'SELECT pg_try_advisory_lock(hashtext(?))', + 'SELECT pg_advisory_unlock(hashtext(?))', + 'insert into users(id) values(2)', + ], array_column($conn->getQueryLog(), 'query')); + + $this->assertNotNull( + DB::connection('pgsql2') + ->advisoryLocker() + ->forSession() + ->tryLock('foo'), + ); + } + + /** + * @throws Throwable + */ + public function testDestructorReleasesLocksAfterRollingBackToSavepoint(): void + { + $conn = DB::connection('pgsql'); + assert($conn instanceof Connection); + $conn->enableQueryLog(); + + try { + $conn->transaction(function (ConnectionInterface $conn): void { + // RefreshDatabase affects to the transaction level + $this->assertSame(2, $conn->transactionLevel()); + + // lockOrFail() doesn't create any savepoints + /** @noinspection PhpUnusedLocalVariableInspection */ + $lock = $conn->advisoryLocker()->forSession()->lockOrFail('foo'); + $this->assertSame(2, $conn->transactionLevel()); + + $conn->insert('insert into users(id) values(1)'); + + try { + // The following statement triggers an error + $conn->insert('insert into users(id) values(1)'); + } catch (QueryException) { + } + // The following statement is invalid [*] + $conn->insert('insert into users(id) values(2)'); + $this->fail(); + }); + $this->fail(); + } catch (QueryException $e) { + // Thrown from [*] + $this->assertSame( + $e->getMessage(), + 'SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: ' + . 'current transaction is aborted, commands ignored until end of transaction block ' + . '(SQL: insert into users(id) values(2))', + ); + } + + $this->assertSame([ + 'SELECT pg_try_advisory_lock(hashtext(?))', + 'insert into users(id) values(1)', + 'SELECT pg_advisory_unlock(hashtext(?))', + ], array_column($conn->getQueryLog(), 'query')); + + $this->assertNotNull( + DB::connection('pgsql2') + ->advisoryLocker() + ->forSession() + ->tryLock('foo'), + ); + } +} diff --git a/tests/TransactionLockerTest.php b/tests/TransactionLockerTest.php index 8d53906..f453633 100644 --- a/tests/TransactionLockerTest.php +++ b/tests/TransactionLockerTest.php @@ -4,6 +4,7 @@ namespace Mpyw\LaravelDatabaseAdvisoryLock\Tests; +use Illuminate\Database\ConnectionInterface; use Illuminate\Support\Facades\DB; use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\InvalidTransactionLevelException; use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException; @@ -24,14 +25,14 @@ public function testDifferentKeysOnDifferentConnections(string $name): void { $passed = false; - DB::connection($name)->transaction(function () use ($name, &$passed): void { - DB::connection($name) + DB::connection($name)->transaction(function (ConnectionInterface $conn) use ($name, &$passed): void { + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('foo'); - DB::connection("{$name}2")->transaction(function () use ($name): void { - DB::connection("{$name}2") + DB::connection("{$name}2")->transaction(function (ConnectionInterface $conn): void { + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('bar'); @@ -49,8 +50,8 @@ public function testDifferentKeysOnDifferentConnections(string $name): void */ public function testSameKeysOnDifferentConnections(string $name): void { - DB::connection($name)->transaction(function () use ($name): void { - DB::connection($name) + DB::connection($name)->transaction(function (ConnectionInterface $conn) use ($name): void { + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('foo'); @@ -58,8 +59,8 @@ public function testSameKeysOnDifferentConnections(string $name): void $this->expectException(LockFailedException::class); $this->expectExceptionMessage('Failed to acquire lock: foo'); - DB::connection("{$name}2")->transaction(function () use ($name): void { - DB::connection("{$name}2") + DB::connection("{$name}2")->transaction(function (ConnectionInterface $conn): void { + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('foo'); @@ -77,13 +78,13 @@ public function testDifferentKeysOnSameConnections(string $name): void { $passed = false; - DB::connection($name)->transaction(function () use ($name, &$passed): void { - DB::connection($name) + DB::connection($name)->transaction(function (ConnectionInterface $conn) use (&$passed): void { + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('foo'); - DB::connection($name) + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('bar'); @@ -102,13 +103,13 @@ public function testSameKeysOnSameConnections(string $name): void { $passed = false; - DB::connection($name)->transaction(function () use ($name, &$passed): void { - DB::connection($name) + DB::connection($name)->transaction(function (ConnectionInterface $conn) use (&$passed): void { + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('foo'); - DB::connection($name) + $conn ->advisoryLocker() ->forTransaction() ->lockOrFail('foo');