├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── _ide_helper.php ├── compose.yaml ├── composer.json ├── extension.neon ├── phpstan.neon ├── phpstan ├── AdvisoryLockerMethod.php └── ConnectionClassExtension.php ├── phpunit.xml ├── src ├── AdvisoryLockServiceProvider.php ├── AdvisoryLocks.php ├── Concerns │ ├── ReleasesWhenDestructed.php │ ├── SessionLocks.php │ └── TransactionalLocks.php ├── ConnectionServiceProvider.php ├── Connections │ ├── MySqlConnection.php │ └── PostgresConnection.php ├── Contracts │ ├── InvalidTransactionLevelException.php │ ├── LockFailedException.php │ ├── LockerFactory.php │ ├── SessionLock.php │ ├── SessionLocker.php │ ├── TransactionLocker.php │ ├── TransactionTerminationListener.php │ ├── UnsupportedDriverException.php │ └── UnsupportedTimeoutPrecisionException.php ├── LockerFactory.php ├── MySqlSessionLock.php ├── MySqlSessionLocker.php ├── PostgresSessionLock.php ├── PostgresSessionLocker.php ├── PostgresTransactionLocker.php ├── TransactionEventHub.php └── Utilities │ ├── PDOStatementEmulator.php │ ├── PostgresTimeoutEmulator.php │ └── Selector.php └── tests ├── AcquiresLockInSeparateProcesses.php ├── PostgresTransactionErrorRecoveryTest.php ├── PostgresTransactionErrorRefreshDatabaseRecoveryTest.php ├── ReconnectionToleranceTest.php ├── SessionLockerTest.php ├── TableTestCase.php ├── TestCase.php └── TransactionLockerTest.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | mysql: 11 | image: mysql:8.0 12 | ports: 13 | - '3306:3306' 14 | env: 15 | MYSQL_DATABASE: testing 16 | MYSQL_USER: testing 17 | MYSQL_PASSWORD: testing 18 | MYSQL_ROOT_PASSWORD: testing 19 | options: >- 20 | --health-cmd="mysqladmin ping" 21 | --health-interval=10s 22 | --health-timeout=30s 23 | --health-retries=5 24 | mariadb: 25 | image: mariadb:10.0 26 | ports: 27 | - '3307:3306' 28 | env: 29 | MYSQL_DATABASE: testing 30 | MYSQL_USER: testing 31 | MYSQL_PASSWORD: testing 32 | MYSQL_ROOT_PASSWORD: testing 33 | options: >- 34 | --health-cmd="mysqladmin ping" 35 | --health-interval=10s 36 | --health-timeout=30s 37 | --health-retries=5 38 | postgres: 39 | image: postgres:13.3 40 | ports: 41 | - '5432:5432' 42 | env: 43 | POSTGRES_DB: testing 44 | POSTGRES_USER: testing 45 | POSTGRES_PASSWORD: testing 46 | options: >- 47 | --health-cmd=pg_isready 48 | --health-interval=10s 49 | --health-timeout=30s 50 | --health-retries=5 51 | 52 | strategy: 53 | matrix: 54 | php: [8.2, 8.3, 8.4] 55 | laravel: [^11.0, ^12.0, ^13.0.x-dev] 56 | exclude: 57 | - php: 8.2 58 | laravel: ^13.0.x-dev 59 | include: 60 | - php: 8.2 61 | php-cs-fixer: 1 62 | - php: 8.3 63 | php-cs-fixer: 1 64 | - laravel: ^11.0 65 | larastan: 1 66 | - laravel: ^12.0 67 | larastan: 1 68 | 69 | steps: 70 | - uses: actions/checkout@v3 71 | 72 | - name: Setup PHP 73 | uses: shivammathur/setup-php@v2 74 | with: 75 | php-version: ${{ matrix.php }} 76 | coverage: xdebug 77 | 78 | - name: Remove impossible dependencies (nunomaduro/larastan) 79 | if: ${{ matrix.larastan != 1 }} 80 | run: composer remove nunomaduro/larastan --dev --no-update 81 | 82 | - name: Remove impossible dependencies (friendsofphp/php-cs-fixer) 83 | if: ${{ matrix.php-cs-fixer != 1 }} 84 | run: composer remove friendsofphp/php-cs-fixer --dev --no-update 85 | 86 | - name: Adjust Package Versions 87 | run: | 88 | composer require "laravel/framework:${{ matrix.laravel }}" --dev --no-update 89 | composer update 90 | 91 | - name: Prepare Coverage Directory 92 | run: mkdir -p build/logs 93 | 94 | - name: PHP-CS-Fixer 95 | if: ${{ matrix.php-cs-fixer == 1 }} 96 | run: composer cs 97 | 98 | - name: PHPStan 99 | if: ${{ matrix.larastan == 1 }} 100 | run: composer phpstan 101 | 102 | - name: Test 103 | run: composer test -- --testdox --coverage-clover build/logs/clover.xml 104 | env: 105 | PG_HOST: 127.0.0.1 106 | MY_HOST: 127.0.0.1 107 | MA_HOST: 127.0.0.1 108 | MA_PORT: '3307' 109 | 110 | - name: Upload Coverage 111 | uses: nick-invision/retry@v2 112 | env: 113 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 | COVERALLS_PARALLEL: 'true' 115 | COVERALLS_FLAG_NAME: "laravel:${{ matrix.laravel }} php:${{ matrix.php }}" 116 | with: 117 | timeout_minutes: 1 118 | max_attempts: 3 119 | command: | 120 | composer global require php-coveralls/php-coveralls 121 | php-coveralls --coverage_clover=build/logs/clover.xml -v 122 | 123 | coverage-aggregation: 124 | needs: build 125 | runs-on: ubuntu-latest 126 | steps: 127 | - name: Aggregate Coverage 128 | uses: coverallsapp/github-action@master 129 | with: 130 | github-token: ${{ secrets.GITHUB_TOKEN }} 131 | parallel-finished: true 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | /.idea/ 3 | /vendor/ 4 | /build/logs/ 5 | .php-cs-fixer.cache 6 | /.phpunit.cache/ 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setFinder( 10 | (new Finder()) 11 | ->in([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/phpstan', 14 | __DIR__ . '/tests', 15 | ]), 16 | ) 17 | ->setRules([ 18 | '@Symfony' => true, 19 | '@Symfony:risky' => true, 20 | '@PhpCsFixer' => true, 21 | '@PhpCsFixer:risky' => true, 22 | '@PHP80Migration' => true, 23 | '@PHP80Migration:risky' => true, 24 | '@PSR12' => true, 25 | '@PHPUnit84Migration:risky' => true, 26 | 'blank_line_before_statement' => [ 27 | 'statements' => [ 28 | 'break', 29 | 'case', 30 | 'continue', 31 | 'declare', 32 | 'default', 33 | 'exit', 34 | 'goto', 35 | 'include', 36 | 'include_once', 37 | 'phpdoc', 38 | 'require', 39 | 'require_once', 40 | 'return', 41 | 'switch', 42 | 'throw', 43 | 'try', 44 | ], 45 | ], 46 | 'cast_spaces' => ['space' => 'none'], 47 | 'concat_space' => ['spacing' => 'one'], 48 | 'control_structure_continuation_position' => true, 49 | 'date_time_immutable' => true, 50 | 'declare_parentheses' => true, 51 | 'echo_tag_syntax' => ['format' => 'short'], 52 | 'final_internal_class' => false, 53 | 'general_phpdoc_annotation_remove' => true, 54 | 'global_namespace_import' => [ 55 | 'import_classes' => true, 56 | 'import_constants' => true, 57 | 'import_functions' => true, 58 | ], 59 | 'heredoc_indentation' => false, 60 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'], 61 | 'native_constant_invocation' => false, 62 | 'native_function_invocation' => false, 63 | 'nullable_type_declaration_for_default_null_value' => true, 64 | 'php_unit_internal_class' => false, 65 | 'php_unit_method_casing' => false, 66 | 'php_unit_strict' => false, 67 | 'php_unit_test_annotation' => false, 68 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], 69 | 'php_unit_test_class_requires_covers' => false, 70 | 'phpdoc_line_span' => true, 71 | 'phpdoc_separation' => false, 72 | 'phpdoc_summary' => false, 73 | 'phpdoc_to_comment' => ['ignored_tags' => ['noinspection']], 74 | 'phpdoc_types_order' => false, 75 | 'regular_callable_call' => true, 76 | 'simplified_if_return' => true, 77 | 'simplified_null_return' => true, 78 | 'single_line_throw' => false, 79 | 'trailing_comma_in_multiline' => [ 80 | 'elements' => ['arrays', 'arguments', 'parameters'], 81 | ], 82 | 'types_spaces' => false, 83 | 'use_arrow_functions' => false, 84 | 'yoda_style' => [ 85 | 'equal' => false, 86 | 'identical' => false, 87 | 'less_and_greater' => false, 88 | ], 89 | ]) 90 | ->setRiskyAllowed(true); 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 mpyw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Database Advisory Lock [![Build Status](https://github.com/mpyw/laravel-database-advisory-lock/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/mpyw/laravel-database-advisory-lock/actions) [![Coverage Status](https://coveralls.io/repos/github/mpyw/laravel-database-advisory-lock/badge.svg?branch=master)](https://coveralls.io/github/mpyw/laravel-database-advisory-lock?branch=master) 2 | 3 | Advisory Locking Features of Postgres/MySQL/MariaDB on Laravel 4 | 5 | ## Requirements 6 | 7 | | Package | Version | Mandatory | 8 | |:--------|:--------------------------------------|:---------:| 9 | | PHP | ^8.2 | ✅ | 10 | | Laravel | ^11.0 || ^12.0 | ✅ | 11 | | PHPStan | >=2.0 | | 12 | 13 | > [!NOTE] 14 | > Older versions have outdated dependency requirements. If you cannot prepare the latest environment, please refer to past releases. 15 | 16 | | RDBMS | Version | 17 | |:---------|:--------------------------| 18 | | Postgres | >=9.1.14 | 19 | | MySQL | >=5.7.5 | 20 | | MariaDB | >=10.0.15 | 21 | 22 | ## Installing 23 | 24 | ``` 25 | composer require mpyw/laravel-database-advisory-lock:^4.4 26 | ``` 27 | 28 | ## Basic usage 29 | 30 | > [!IMPORTANT] 31 | > The default implementation is provided by `ConnectionServiceProvider`, however, **package discovery is not available**. 32 | > Be careful that you MUST register it in **`config/app.php`** by yourself. 33 | 34 | ```php 35 | [ 42 | /* ... */ 43 | 44 | Mpyw\LaravelDatabaseAdvisoryLock\ConnectionServiceProvider::class, 45 | 46 | /* ... */ 47 | ], 48 | 49 | ]; 50 | ``` 51 | 52 | ```php 53 | forSession() 61 | ->withLocking('', function (ConnectionInterface $conn) { 62 | // critical section here 63 | return ...; 64 | }); // no wait 65 | $result = DB::advisoryLocker() 66 | ->forSession() 67 | ->withLocking('', function (ConnectionInterface $conn) { 68 | // critical section here 69 | return ...; 70 | }, timeout: 5); // wait for 5 seconds or fail 71 | $result = DB::advisoryLocker() 72 | ->forSession() 73 | ->withLocking('', function (ConnectionInterface $conn) { 74 | // critical section here 75 | return ...; 76 | }, timeout: -1); // infinite wait (except MariaDB) 77 | 78 | // Postgres only feature: Transaction-Level Locking (no wait) 79 | $result = DB::transaction(function (ConnectionInterface $conn) { 80 | $conn->advisoryLocker()->forTransaction()->lockOrFail(''); 81 | // critical section here 82 | return ...; 83 | }); 84 | ``` 85 | 86 | ## Advanced Usage 87 | 88 | > [!TIP] 89 | > You can extend Connection classes with `AdvisoryLocks` trait by yourself. 90 | 91 | ```php 92 | ') 132 | ``` 133 | 134 | ```sql 135 | -- MySQL/MariaDB: varchar(64) 136 | CASE WHEN CHAR_LENGTH('') > 64 137 | THEN CONCAT(SUBSTR('', 1, 24), SHA1('')) 138 | ELSE '' 139 | END 140 | ``` 141 | 142 | - Postgres advisory locking functions only accept integer keys. So the driver converts key strings into 64-bit integers through `hashtext()` function. 143 | - An empty string can also be used as a key. 144 | - MySQL advisory locking function 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. 145 | - MariaDB's limit is actually 192 bytes, unlike MySQL's 64 chars. However, the key hashing algorithm is equivalent. 146 | - MariaDB accepts an empty string as a key, but does not actually lock anything. MySQL, on the other hand, raises an error for empty string keys. 147 | - With either hashing algorithm, collisions can theoretically occur with very low probability. 148 | 149 | ### Locking Methods 150 | 151 | | | Postgres | MySQL/MariaDB | 152 | |:--------------------------|:---------:|:-------------:| 153 | | Session-Level Locking | ✅ | ✅ | 154 | | Transaction-Level Locking | ✅ | ❌ | 155 | 156 | - Session-Level locks can be acquired anywhere. 157 | - They can be released manually or automatically through a destructor. 158 | - 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. 159 | - Transaction-Level locks can be acquired within a transaction. 160 | - You do not need to and cannot manually release locks that have been acquired. 161 | 162 | ### Timeout Values 163 | 164 | | | Postgres | MySQL | MariaDB | 165 | |:-------------------------------------------|:---------------:|:-----:|:-------:| 166 | | Timeout: `0` (default; immediate, no wait) | ✅ | ✅ | ✅ | 167 | | Timeout: `positive-int` | ✅ | ✅ | ✅ | 168 | | Timeout: `negative-int` (infinite wait) | ✅ | ✅ | ❌ | 169 | | Timeout: `float` | ✅ | ❌ | ❌ | 170 | 171 | - Postgres does not natively support waiting for a finite specific amount of time, but this is emulated by looping through a temporary function. 172 | - MariaDB does not accept infinite timeouts. very large numbers can be used instead. 173 | - Float precision is not supported on MySQL/MariaDB. 174 | 175 | ## Caveats about Transaction Levels 176 | 177 | ### Recommended Approach 178 | 179 | When transactions and advisory locks are related, either locking approach can be applied. 180 | 181 | > [!TIP] 182 | > **For Postgres, always prefer Transaction-Level Locking.** 183 | 184 | > [!NOTE] 185 | > **Transaction-Level Locks:** 186 | > Ensure the current context is inside the transaction, then rely on automatic release mechanisms. 187 | > 188 | > ```php 189 | > if (DB::transactionLevel() < 1) { 190 | > throw new LogicException("Unexpectedly transaction is not active."); 191 | > } 192 | > 193 | > DB::advisoryLocker() 194 | > ->forTransaction() 195 | > ->lockOrFail(''); 196 | > // critical section with transaction here 197 | > ``` 198 | 199 | > [!NOTE] 200 | > **Session-Level Locks:** 201 | > Ensure the current context is outside the transaction, then proceed to call `DB::transaction()` call. 202 | > 203 | > ```php 204 | > if (DB::transactionLevel() > 0) { 205 | > throw new LogicException("Unexpectedly transaction is already active."); 206 | > } 207 | > 208 | > $result = DB::advisoryLocker() 209 | > ->forSession() 210 | > ->withLocking('', fn (ConnectionInterface $conn) => $conn->transaction(function () { 211 | > // critical section with transaction here 212 | > })); 213 | > ``` 214 | 215 | > [!WARNING] 216 | > When writing logic like this, [`DatabaseTruncation`](https://github.com/laravel/framework/blob/87b9e7997e178dfc4acd5e22fa8d77ba333c3abd/src/Illuminate/Foundation/Testing/DatabaseTruncation.php) must be used instead of [`RefreshDatabase`](https://github.com/laravel/framework/blob/87b9e7997e178dfc4acd5e22fa8d77ba333c3abd/src/Illuminate/Foundation/Testing/RefreshDatabase.php). 217 | 218 | ### Considerations 219 | 220 | > [!CAUTION] 221 | > **Session-Level Locks:** 222 | > Don't take session-level locks in the transactions when the content to be committed by the transaction is related to the advisory locks. 223 | > 224 | > What would happen if we released a session-level lock within a transaction? Let's verify this with a timeline chart, assuming a `READ COMMITTED` isolation level on Postgres. The bank account X is operated from two sessions A and B concurrently. 225 | > 226 | > | Session A | Session B | 227 | > |:-------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------| 228 | > | `BEGIN` | | 229 | > | ︙ | `BEGIN` | 230 | > | `pg_advisory_lock(X)` | ︙ | 231 | > | ︙ | `pg_advisory_lock(X)` | 232 | > | Fetch balance of User X
(Balance: 1000 USD) | ︙ | 233 | > | ︙ | ︙ | 234 | > | Deduct 800 USD if balance permits
(Balance: 1000 USD → 200 USD) | ︙ | 235 | > | ︙ | ︙ | 236 | > | `pg_advisory_unlock(X)` | ︙ | 237 | > | ︙ | Fetch balance of User X
**(Balance: 1000 USD :heavy_exclamation_mark:)** | 238 | > | ︙ | ︙ | 239 | > | ︙ | Deduct 800 USD if balance permits
**(Balance: 1000 USD → 200 USD :bangbang:)** | 240 | > | `COMMIT` | ︙ | 241 | > | ︙ | `pg_advisory_unlock(X)` | 242 | > | Fetch balance of User X
(Balance: 200 USD) | ︙ | 243 | > | | `COMMIT` | 244 | > | | ︙ | 245 | > | | Fetch balance of User X
(**Balance: -600 USD** :interrobang::interrobang::interrobang:) | 246 | -------------------------------------------------------------------------------- /_ide_helper.php: -------------------------------------------------------------------------------- 1 | =9.0", 35 | "phpunit/phpunit": ">=11.0", 36 | "phpstan/phpstan": ">=2.0", 37 | "phpstan/extension-installer": ">=1.1", 38 | "nunomaduro/larastan": ">=3.1", 39 | "friendsofphp/php-cs-fixer": "^3.70" 40 | }, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit", 43 | "phpstan": "vendor/bin/phpstan analyse --level=9 --memory-limit=2G src tests phpstan", 44 | "cs": "vendor/bin/php-cs-fixer fix --dry-run", 45 | "cs:fix": "vendor/bin/php-cs-fixer fix" 46 | }, 47 | "minimum-stability": "dev", 48 | "prefer-stable": true, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Mpyw\\LaravelDatabaseAdvisoryLock\\AdvisoryLockServiceProvider" 53 | ] 54 | }, 55 | "phpstan": { 56 | "includes": [ 57 | "extension.neon" 58 | ] 59 | } 60 | }, 61 | "config": { 62 | "allow-plugins": { 63 | "phpstan/extension-installer": true 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | services: 2 | - 3 | class: Mpyw\LaravelDatabaseAdvisoryLock\PHPStan\ConnectionClassExtension 4 | tags: 5 | - phpstan.broker.methodsClassReflectionExtension 6 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - extension.neon 3 | 4 | parameters: 5 | ignoreErrors: 6 | - 7 | message: '#no value type specified in iterable type array#' 8 | paths: 9 | - src/AdvisoryLocks.php 10 | - src/Contracts/LockFailedException.php 11 | - src/Utilities/Selector.php 12 | - tests/*.php 13 | -------------------------------------------------------------------------------- /phpstan/AdvisoryLockerMethod.php: -------------------------------------------------------------------------------- 1 | class = $classReflection; 25 | } 26 | 27 | public function getDeclaringClass(): ClassReflection 28 | { 29 | return $this->class; 30 | } 31 | 32 | public function isStatic(): bool 33 | { 34 | return $this->class->is(DB::class); 35 | } 36 | 37 | public function isPrivate(): bool 38 | { 39 | return false; 40 | } 41 | 42 | public function isPublic(): bool 43 | { 44 | return true; 45 | } 46 | 47 | public function getDocComment(): ?string 48 | { 49 | return null; 50 | } 51 | 52 | public function getName(): string 53 | { 54 | return 'advisoryLocker'; 55 | } 56 | 57 | public function getPrototype(): ClassMemberReflection 58 | { 59 | return $this; 60 | } 61 | 62 | public function getVariants(): array 63 | { 64 | return [new FunctionVariant( 65 | TemplateTypeMap::createEmpty(), 66 | null, 67 | [], 68 | false, 69 | new ObjectType(LockerFactory::class), 70 | )]; 71 | } 72 | 73 | public function isDeprecated(): TrinaryLogic 74 | { 75 | return TrinaryLogic::createNo(); 76 | } 77 | 78 | public function getDeprecatedDescription(): ?string 79 | { 80 | return null; 81 | } 82 | 83 | public function isFinal(): TrinaryLogic 84 | { 85 | return TrinaryLogic::createNo(); 86 | } 87 | 88 | public function isInternal(): TrinaryLogic 89 | { 90 | return TrinaryLogic::createNo(); 91 | } 92 | 93 | public function getThrowType(): ?Type 94 | { 95 | return null; 96 | } 97 | 98 | public function hasSideEffects(): TrinaryLogic 99 | { 100 | return TrinaryLogic::createNo(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /phpstan/ConnectionClassExtension.php: -------------------------------------------------------------------------------- 1 | is(ConnectionInterface::class) 20 | || $classReflection->is(DB::class) 21 | ); 22 | } 23 | 24 | public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection 25 | { 26 | return new AdvisoryLockerMethod($classReflection); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | ./src 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ./tests 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/AdvisoryLockServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(TransactionEventHub::class); 19 | } 20 | 21 | public function boot(TransactionEventHub $hub): void 22 | { 23 | TransactionEventHub::setResolver(static fn () => $hub); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/AdvisoryLocks.php: -------------------------------------------------------------------------------- 1 | advisoryLocker ??= new LockerFactory($this); 29 | } 30 | 31 | /** 32 | * Overrides the original implementation. 33 | * 34 | * @param string $query 35 | * @param array $bindings 36 | * @throws QueryException 37 | */ 38 | protected function handleQueryException(QueryException $e, $query, $bindings, Closure $callback) 39 | { 40 | assert($this instanceof Connection); 41 | 42 | // Don't try again if there are session-level locks. 43 | if ($this->transactionLevel() > 0 || $this->advisoryLocker()->forSession()->hasAny()) { 44 | throw $e; 45 | } 46 | 47 | return $this->tryAgainIfCausedByLostConnection( 48 | $e, 49 | $query, 50 | $bindings, 51 | $callback, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Concerns/ReleasesWhenDestructed.php: -------------------------------------------------------------------------------- 1 | release(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Concerns/SessionLocks.php: -------------------------------------------------------------------------------- 1 | lockOrFail($key, $timeout); 18 | } catch (LockFailedException) { 19 | return null; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Concerns/TransactionalLocks.php: -------------------------------------------------------------------------------- 1 | lockOrFail($key, $timeout); 15 | 16 | return true; 17 | } catch (LockFailedException) { 18 | return false; 19 | } 20 | } 21 | 22 | abstract public function lockOrFail(string $key, float|int $timeout = 0): void; 23 | } 24 | -------------------------------------------------------------------------------- /src/ConnectionServiceProvider.php: -------------------------------------------------------------------------------- 1 | new MySqlConnection(...$args)); 20 | Connection::resolverFor('pgsql', static fn (...$args) => new PostgresConnection(...$args)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Connections/MySqlConnection.php: -------------------------------------------------------------------------------- 1 | connection instanceof PostgresConnection) { 26 | return $this->transaction ??= new PostgresTransactionLocker($this->connection); 27 | } 28 | 29 | // @codeCoverageIgnoreStart 30 | throw new UnsupportedDriverException('TransactionLocker is not supported'); 31 | // @codeCoverageIgnoreEnd 32 | } 33 | 34 | public function forSession(): SessionLocker 35 | { 36 | if ($this->connection instanceof MySqlConnection) { 37 | return $this->session ??= new MySqlSessionLocker($this->connection); 38 | } 39 | if ($this->connection instanceof PostgresConnection) { 40 | return $this->session ??= new PostgresSessionLocker($this->connection); 41 | } 42 | 43 | // @codeCoverageIgnoreStart 44 | throw new UnsupportedDriverException('SessionLocker is not supported'); 45 | // @codeCoverageIgnoreEnd 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/MySqlSessionLock.php: -------------------------------------------------------------------------------- 1 | $locks 23 | */ 24 | public function __construct( 25 | private MySqlConnection $connection, 26 | private WeakMap $locks, 27 | private string $key, 28 | ) {} 29 | 30 | public function release(): bool 31 | { 32 | if (!$this->released) { 33 | // When key strings exceed 64 chars limit, 34 | // it takes first 24 chars from them and appends 40 chars `sha1()` hashes. 35 | $sql = 'SELECT RELEASE_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END)'; 36 | 37 | $this->released = (bool)(new Selector($this->connection)) 38 | ->select($sql, array_fill(0, 4, $this->key)); 39 | 40 | // Clean up the lock when it succeeds. 41 | $this->released && $this->locks->offsetUnset($this); 42 | } 43 | 44 | return $this->released; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/MySqlSessionLocker.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private WeakMap $locks; 28 | 29 | public function __construct( 30 | private MySqlConnection $connection, 31 | ) { 32 | $this->locks = new WeakMap(); 33 | } 34 | 35 | public function lockOrFail(string $key, float|int $timeout = 0): SessionLock 36 | { 37 | if (is_float($timeout)) { 38 | throw new UnsupportedTimeoutPrecisionException(sprintf( 39 | 'Float timeout value is not allowed for MySQL/MariaDB: key=%s, timeout=%s', 40 | $key, 41 | $timeout, 42 | )); 43 | } 44 | 45 | // When key strings exceed 64 chars limit, 46 | // it takes first 24 chars from them and appends 40 chars `sha1()` hashes. 47 | $sql = "SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, {$timeout})"; 48 | $bindings = array_fill(0, 4, $key); 49 | 50 | $result = (bool)(new Selector($this->connection)) 51 | ->select($sql, $bindings); 52 | 53 | if (!$result) { 54 | throw new LockFailedException( 55 | (string)$this->connection->getName(), 56 | "Failed to acquire lock: {$key}", 57 | $sql, 58 | $bindings, 59 | ); 60 | } 61 | 62 | // Register the lock when it succeeds. 63 | $lock = new MySqlSessionLock($this->connection, $this->locks, $key); 64 | $this->locks[$lock] = true; 65 | 66 | return $lock; 67 | } 68 | 69 | public function withLocking(string $key, callable $callback, float|int $timeout = 0): mixed 70 | { 71 | $lock = $this->lockOrFail($key, $timeout); 72 | 73 | try { 74 | return $callback($this->connection); 75 | } finally { 76 | $lock->release(); 77 | } 78 | } 79 | 80 | public function hasAny(): bool 81 | { 82 | return $this->locks->count() > 0; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/PostgresSessionLock.php: -------------------------------------------------------------------------------- 1 | $locks 26 | */ 27 | public function __construct( 28 | private PostgresConnection $connection, 29 | private WeakMap $locks, 30 | private string $key, 31 | ) { 32 | $this->hub = TransactionEventHub::resolve(); 33 | $this->hub?->initializeWithDispatcher($this->connection->getEventDispatcher()); 34 | } 35 | 36 | public function release(): bool 37 | { 38 | if (!$this->released) { 39 | try { 40 | $this->released = (bool)(new Selector($this->connection)) 41 | ->select('SELECT pg_advisory_unlock(hashtext(?))', [$this->key]); 42 | } catch (PDOException $e) { 43 | // Postgres can't release session-level locks immediately 44 | // when an error occurs within a transaction. 45 | // Register onTransactionTerminated() for releasing 46 | // after the transaction is terminated or rewinding to a savepoint. 47 | self::causedByTransactionAbort($e) 48 | ? $this->hub?->registerOnceListener($this) 49 | : throw $e; // @codeCoverageIgnore 50 | } 51 | 52 | // Clean up the lock when it succeeds. 53 | $this->released && $this->locks->offsetUnset($this); 54 | } 55 | 56 | return $this->released; 57 | } 58 | 59 | /** 60 | * @see https://www.postgresql.org/docs/current/errcodes-appendix.html 61 | */ 62 | private static function causedByTransactionAbort(PDOException $e): bool 63 | { 64 | return $e->getCode() === '25P02'; 65 | } 66 | 67 | public function onTransactionTerminated(TransactionCommitted|TransactionRolledBack $event): void 68 | { 69 | $this->release(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/PostgresSessionLocker.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | protected WeakMap $locks; 24 | 25 | public function __construct( 26 | private PostgresConnection $connection, 27 | ) { 28 | $this->locks = new WeakMap(); 29 | } 30 | 31 | /** 32 | * {@inheritDoc} 33 | * 34 | * Use of this method is strongly discouraged in Postgres. Use withLocking() instead. 35 | */ 36 | public function lockOrFail(string $key, float|int $timeout = 0): SessionLock 37 | { 38 | if ($timeout > 0) { 39 | // Positive timeout can be performed through temporary function 40 | $emulator = new PostgresTimeoutEmulator($this->connection); 41 | $sql = $emulator->sql($timeout, false); 42 | $result = $emulator->performWithTimeout($key, $timeout); 43 | } else { 44 | // Negative timeout means infinite wait 45 | // Zero timeout means no wait 46 | $sql = $timeout < 0 47 | ? "SELECT pg_advisory_lock(hashtext(?))::text = ''" 48 | : 'SELECT pg_try_advisory_lock(hashtext(?))'; 49 | 50 | $selector = new Selector($this->connection); 51 | $result = (bool)$selector->select($sql, [$key]); 52 | } 53 | 54 | if (!$result) { 55 | throw new LockFailedException( 56 | (string)$this->connection->getName(), 57 | "Failed to acquire lock: {$key}", 58 | $sql, 59 | [$key], 60 | ); 61 | } 62 | 63 | // Register the lock when it succeeds. 64 | $lock = new PostgresSessionLock($this->connection, $this->locks, $key); 65 | $this->locks[$lock] = true; 66 | 67 | return $lock; 68 | } 69 | 70 | public function withLocking(string $key, callable $callback, float|int $timeout = 0): mixed 71 | { 72 | $lock = $this->lockOrFail($key, $timeout); 73 | 74 | try { 75 | // In Postgres, savepoints allow recovery from errors. 76 | // This ensures release() on finally. 77 | /** @noinspection PhpUnhandledExceptionInspection */ 78 | return $this->connection->transactionLevel() > 0 79 | ? $this->connection->transaction(fn () => $callback($this->connection)) 80 | : $callback($this->connection); 81 | } finally { 82 | $lock->release(); 83 | } 84 | } 85 | 86 | public function hasAny(): bool 87 | { 88 | return $this->locks->count() > 0; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/PostgresTransactionLocker.php: -------------------------------------------------------------------------------- 1 | connection->transactionLevel() < 1) { 26 | throw new InvalidTransactionLevelException('There are no transactions'); 27 | } 28 | 29 | if ($timeout > 0) { 30 | // Positive timeout can be performed through temporary function 31 | $emulator = new PostgresTimeoutEmulator($this->connection); 32 | $sql = $emulator->sql($timeout, false); 33 | $result = $emulator->performWithTimeout($key, $timeout, true); 34 | } else { 35 | // Negative timeout means infinite wait 36 | // Zero timeout means no wait 37 | $sql = $timeout < 0 38 | ? "SELECT pg_advisory_xact_lock(hashtext(?))::text = ''" 39 | : 'SELECT pg_try_advisory_xact_lock(hashtext(?))'; 40 | 41 | $selector = new Selector($this->connection); 42 | $result = (bool)$selector->select($sql, [$key]); 43 | } 44 | 45 | if (!$result) { 46 | throw new LockFailedException( 47 | (string)$this->connection->getName(), 48 | "Failed to acquire lock: {$key}", 49 | $sql, 50 | [$key], 51 | ); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/TransactionEventHub.php: -------------------------------------------------------------------------------- 1 | > 26 | */ 27 | private WeakMap $dispatchersAndListeners; 28 | 29 | /** 30 | * @var null|callable(): self 31 | */ 32 | private static $resolver; 33 | 34 | /** 35 | * Set a singleton instance resolver. 36 | * 37 | * @param null|callable(): self $resolver 38 | */ 39 | public static function setResolver(?callable $resolver): void 40 | { 41 | self::$resolver = $resolver; 42 | } 43 | 44 | /** 45 | * Create or retrieve a singleton instance through resolver. 46 | */ 47 | public static function resolve(): ?self 48 | { 49 | return self::$resolver ? (self::$resolver)() : null; 50 | } 51 | 52 | public function __construct() 53 | { 54 | $this->dispatchersAndListeners = new WeakMap(); 55 | } 56 | 57 | /** 58 | * Register self::onTransactionTerminated() as a listener once per connection. 59 | */ 60 | public function initializeWithDispatcher(Dispatcher $dispatcher): void 61 | { 62 | if (!isset($this->dispatchersAndListeners[$dispatcher])) { 63 | $dispatcher->listen( 64 | [TransactionCommitted::class, TransactionRolledBack::class], 65 | [self::class, 'onTransactionTerminated'], 66 | ); 67 | } 68 | 69 | $this->dispatchersAndListeners[$dispatcher] ??= []; 70 | } 71 | 72 | /** 73 | * Register underlying user listener per connection. 74 | * Listeners registered here are invoked only once. 75 | */ 76 | public function registerOnceListener(TransactionTerminationListener $listener): void 77 | { 78 | foreach ($this->dispatchersAndListeners as $dispatcher => $_) { 79 | $this->dispatchersAndListeners[$dispatcher][spl_object_hash($listener)] = $listener; 80 | } 81 | } 82 | 83 | /** 84 | * Fire on events. 85 | */ 86 | public function onTransactionTerminated(TransactionCommitted|TransactionRolledBack $event): void 87 | { 88 | /** @var array> $savedListenerGroups */ 89 | $savedListenerGroups = []; 90 | 91 | // First, save all listeners. 92 | foreach ($this->dispatchersAndListeners as $dispatcher => $listeners) { 93 | foreach ($listeners as $listener) { 94 | $savedListenerGroups[spl_object_hash($dispatcher)][spl_object_hash($listener)] = $listener; 95 | } 96 | } 97 | 98 | // Next, remove listeners in advance. 99 | foreach ($this->dispatchersAndListeners as $dispatcher => $_) { 100 | $this->dispatchersAndListeners[$dispatcher] = []; 101 | } 102 | 103 | // Finally, run the saved listeners. 104 | // It does not matter if new listeners are registered again during the execution. 105 | foreach ($savedListenerGroups as $savedListeners) { 106 | foreach ($savedListeners as $listener) { 107 | try { 108 | $listener->onTransactionTerminated($event); 109 | // @codeCoverageIgnoreStart 110 | } catch (Throwable) { 111 | } 112 | // @codeCoverageIgnoreEnd 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Utilities/PDOStatementEmulator.php: -------------------------------------------------------------------------------- 1 | getAttribute(PDO::ATTR_EMULATE_PREPARES); 24 | $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true); 25 | 26 | try { 27 | return $callback(); 28 | } finally { 29 | $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, $original); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Utilities/PostgresTimeoutEmulator.php: -------------------------------------------------------------------------------- 1 | connection->getPdo(), 34 | fn () => (bool)(new Selector($this->connection)) 35 | ->select($this->sql($timeout, $forTransaction), [$key]), 36 | ); 37 | } 38 | 39 | /** 40 | * Generates SQL to emulate time-limited lock acquisition. 41 | * 42 | * @phpstan-param positive-int|float $timeout 43 | */ 44 | public function sql(float|int $timeout, bool $forTransaction): string 45 | { 46 | $suffix = $forTransaction ? '_xact' : ''; 47 | $modifier = $forTransaction ? 'LOCAL' : 'SESSION'; 48 | 49 | $sql = <<connection 36 | ->selectOne($sql, $bindings, false); 37 | 38 | return array_shift($row); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/AcquiresLockInSeparateProcesses.php: -------------------------------------------------------------------------------- 1 | query("SELECT GET_LOCK('{$key}', 0)")->fetchColumn(); 41 | sleep({$sleep}); 42 | exit(\$result == 1 ? 0 : 1); 43 | EOD, 44 | ]); 45 | $proc->start(); 46 | 47 | return $proc; 48 | } 49 | 50 | private static function lockMariadbAsync(string $key, int $sleep): Process 51 | { 52 | return self::lockMysqlAsync($key, $sleep, 'mariadb'); 53 | } 54 | 55 | private static function lockPostgresAsync(string $key, int $sleep): Process 56 | { 57 | $host = config('database.connections.pgsql.host'); 58 | assert(is_string($host)); 59 | 60 | $port = config('database.connections.pgsql.port'); 61 | assert(is_scalar($port)); 62 | 63 | $proc = new Process([PHP_BINARY, '-r', 64 | <<query("SELECT pg_try_advisory_lock(hashtext('{$key}'))")->fetchColumn(); 67 | sleep({$sleep}); 68 | exit(\$result ? 0 : 1); 69 | EOD, 70 | ]); 71 | $proc->start(); 72 | 73 | return $proc; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/PostgresTransactionErrorRecoveryTest.php: -------------------------------------------------------------------------------- 1 | enableQueryLog(); 23 | 24 | $conn 25 | ->advisoryLocker() 26 | ->forSession() 27 | ->withLocking('foo', function (ConnectionInterface $conn) use (&$passed): void { 28 | $this->assertSame(0, $conn->transactionLevel()); 29 | $conn->insert('insert into users(id) values(1)'); 30 | 31 | try { 32 | // The following statement triggers an error 33 | $conn->insert('insert into users(id) values(1)'); 34 | } catch (QueryException) { 35 | } 36 | // The following statement is valid because there are no transactions 37 | $conn->insert('insert into users(id) values(2)'); 38 | $passed = true; 39 | }); 40 | 41 | $this->assertTrue($passed); 42 | $this->assertSame([ 43 | 'SELECT pg_try_advisory_lock(hashtext(?))', 44 | 'insert into users(id) values(1)', 45 | 'insert into users(id) values(2)', 46 | 'SELECT pg_advisory_unlock(hashtext(?))', 47 | ], array_column($conn->getQueryLog(), 'query')); 48 | 49 | $this->assertNotNull( 50 | DB::connection('pgsql2') 51 | ->advisoryLocker() 52 | ->forSession() 53 | ->tryLock('foo'), 54 | ); 55 | } 56 | 57 | /** 58 | * @throws Throwable 59 | */ 60 | public function testWithLockingRollbacksToSavepoint(): void 61 | { 62 | $passed = false; 63 | 64 | $conn = DB::connection('pgsql'); 65 | $conn->enableQueryLog(); 66 | 67 | $conn->transaction(function (ConnectionInterface $conn) use (&$passed): void { 68 | $this->assertSame(1, $conn->transactionLevel()); 69 | $conn->insert('insert into users(id) values(1)'); 70 | 71 | try { 72 | $conn 73 | ->advisoryLocker() 74 | ->forSession() 75 | ->withLocking('foo', function (ConnectionInterface $conn): void { 76 | // The level is 2 because savepoint is automatically created 77 | $this->assertSame(2, $conn->transactionLevel()); 78 | 79 | // The following statement triggers an error 80 | $conn->insert('insert into users(id) values(1)'); 81 | $this->fail(); 82 | }); 83 | // @phpstan-ignore-next-line 84 | $this->fail(); 85 | } catch (QueryException) { 86 | } 87 | // The following statement is valid because it is rolled back to the savepoint 88 | $this->assertSame(1, $conn->transactionLevel()); 89 | $conn->insert('insert into users(id) values(2)'); 90 | $passed = true; 91 | }); 92 | 93 | $this->assertTrue($passed); 94 | $this->assertSame([ 95 | 'insert into users(id) values(1)', 96 | 'SELECT pg_try_advisory_lock(hashtext(?))', 97 | 'SELECT pg_advisory_unlock(hashtext(?))', 98 | 'insert into users(id) values(2)', 99 | ], array_column($conn->getQueryLog(), 'query')); 100 | 101 | $this->assertNotNull( 102 | DB::connection('pgsql2') 103 | ->advisoryLocker() 104 | ->forSession() 105 | ->tryLock('foo'), 106 | ); 107 | } 108 | 109 | /** 110 | * @throws Throwable 111 | */ 112 | public function testDestructorReleasesLocksAfterTransactionTerminated(): void 113 | { 114 | $conn = DB::connection('pgsql'); 115 | $conn->enableQueryLog(); 116 | 117 | try { 118 | $conn->transaction(function (ConnectionInterface $conn): void { 119 | // lockOrFail() doesn't create any savepoints 120 | $this->assertSame(1, $conn->transactionLevel()); 121 | 122 | /** @noinspection PhpUnusedLocalVariableInspection */ 123 | $lock = $conn->advisoryLocker()->forSession()->lockOrFail('foo'); 124 | $this->assertSame(1, $conn->transactionLevel()); 125 | 126 | $conn->insert('insert into users(id) values(1)'); 127 | 128 | try { 129 | // The following statement triggers an error 130 | $conn->insert('insert into users(id) values(1)'); 131 | } catch (QueryException) { 132 | } 133 | // The following statement is invalid [*] 134 | $conn->insert('insert into users(id) values(2)'); 135 | $this->fail(); 136 | }); 137 | // @phpstan-ignore-next-line 138 | $this->fail(); 139 | } catch (QueryException $e) { 140 | // Thrown from [*] 141 | $this->assertSame( 142 | 'SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: ' 143 | . 'current transaction is aborted, commands ignored until end of transaction block ' 144 | . ( 145 | version_compare($this->app?->version() ?? '', '10.x-dev', '>=') 146 | ? '(Connection: pgsql, SQL: insert into users(id) values(2))' 147 | : '(SQL: insert into users(id) values(2))' 148 | ), 149 | $e->getMessage(), 150 | ); 151 | } 152 | 153 | $this->assertSame([ 154 | 'SELECT pg_try_advisory_lock(hashtext(?))', 155 | 'insert into users(id) values(1)', 156 | 'SELECT pg_advisory_unlock(hashtext(?))', 157 | ], array_column($conn->getQueryLog(), 'query')); 158 | 159 | $this->assertNotNull( 160 | DB::connection('pgsql2') 161 | ->advisoryLocker() 162 | ->forSession() 163 | ->tryLock('foo'), 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /tests/PostgresTransactionErrorRefreshDatabaseRecoveryTest.php: -------------------------------------------------------------------------------- 1 | enableQueryLog(); 26 | 27 | try { 28 | $conn 29 | ->advisoryLocker() 30 | ->forSession() 31 | ->withLocking('foo', function (ConnectionInterface $conn): void { 32 | // RefreshDatabase affects to the transaction level 33 | $this->assertSame(2, $conn->transactionLevel()); 34 | $conn->insert('insert into users(id) values(1)'); 35 | 36 | try { 37 | // The following statement triggers an error 38 | $conn->insert('insert into users(id) values(1)'); 39 | } catch (QueryException) { 40 | } 41 | // The following statement is invalid [*] 42 | $conn->insert('insert into users(id) values(2)'); 43 | }); 44 | } catch (QueryException $e) { 45 | // Thrown from [*] 46 | $this->assertSame( 47 | 'SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: ' 48 | . 'current transaction is aborted, commands ignored until end of transaction block ' 49 | . ( 50 | version_compare($this->app?->version() ?? '', '10.x-dev', '>=') 51 | ? '(Connection: pgsql, SQL: insert into users(id) values(2))' 52 | : '(SQL: insert into users(id) values(2))' 53 | ), 54 | $e->getMessage(), 55 | ); 56 | } 57 | 58 | $this->assertSame([ 59 | 'SELECT pg_try_advisory_lock(hashtext(?))', 60 | 'insert into users(id) values(1)', 61 | 'SELECT pg_advisory_unlock(hashtext(?))', 62 | ], array_column($conn->getQueryLog(), 'query')); 63 | 64 | $this->assertNotNull( 65 | DB::connection('pgsql2') 66 | ->advisoryLocker() 67 | ->forSession() 68 | ->tryLock('foo'), 69 | ); 70 | } 71 | 72 | /** 73 | * @throws Throwable 74 | */ 75 | public function testWithLockingRollbacksToSavepoint(): void 76 | { 77 | $passed = false; 78 | 79 | $conn = DB::connection('pgsql'); 80 | $conn->enableQueryLog(); 81 | 82 | $conn->transaction(function (ConnectionInterface $conn) use (&$passed): void { 83 | // RefreshDatabase affects to the transaction level 84 | $this->assertSame(2, $conn->transactionLevel()); 85 | $conn->insert('insert into users(id) values(1)'); 86 | 87 | try { 88 | $conn 89 | ->advisoryLocker() 90 | ->forSession() 91 | ->withLocking('foo', function (ConnectionInterface $conn): void { 92 | // The level is 3 because savepoint is automatically created 93 | $this->assertSame(3, $conn->transactionLevel()); 94 | 95 | // The following statement triggers an error 96 | $conn->insert('insert into users(id) values(1)'); 97 | $this->fail(); 98 | }); 99 | // @phpstan-ignore-next-line 100 | $this->fail(); 101 | } catch (QueryException) { 102 | } 103 | // The following statement is valid because it is rolled back to the savepoint 104 | $this->assertSame(2, $conn->transactionLevel()); 105 | $conn->insert('insert into users(id) values(2)'); 106 | $passed = true; 107 | }); 108 | 109 | $this->assertTrue($passed); 110 | $this->assertSame([ 111 | 'insert into users(id) values(1)', 112 | 'SELECT pg_try_advisory_lock(hashtext(?))', 113 | 'SELECT pg_advisory_unlock(hashtext(?))', 114 | 'insert into users(id) values(2)', 115 | ], array_column($conn->getQueryLog(), 'query')); 116 | 117 | $this->assertNotNull( 118 | DB::connection('pgsql2') 119 | ->advisoryLocker() 120 | ->forSession() 121 | ->tryLock('foo'), 122 | ); 123 | } 124 | 125 | /** 126 | * @throws Throwable 127 | */ 128 | public function testDestructorReleasesLocksAfterRollingBackToSavepoint(): void 129 | { 130 | $conn = DB::connection('pgsql'); 131 | $conn->enableQueryLog(); 132 | 133 | try { 134 | $conn->transaction(function (ConnectionInterface $conn): void { 135 | // RefreshDatabase affects to the transaction level 136 | $this->assertSame(2, $conn->transactionLevel()); 137 | 138 | // lockOrFail() doesn't create any savepoints 139 | /** @noinspection PhpUnusedLocalVariableInspection */ 140 | $lock = $conn->advisoryLocker()->forSession()->lockOrFail('foo'); 141 | $this->assertSame(2, $conn->transactionLevel()); 142 | 143 | $conn->insert('insert into users(id) values(1)'); 144 | 145 | try { 146 | // The following statement triggers an error 147 | $conn->insert('insert into users(id) values(1)'); 148 | } catch (QueryException) { 149 | } 150 | // The following statement is invalid [*] 151 | $conn->insert('insert into users(id) values(2)'); 152 | $this->fail(); 153 | }); 154 | // @phpstan-ignore-next-line 155 | $this->fail(); 156 | } catch (QueryException $e) { 157 | // Thrown from [*] 158 | $this->assertSame( 159 | 'SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: ' 160 | . 'current transaction is aborted, commands ignored until end of transaction block ' 161 | . ( 162 | version_compare($this->app?->version() ?? '', '10.x-dev', '>=') 163 | ? '(Connection: pgsql, SQL: insert into users(id) values(2))' 164 | : '(SQL: insert into users(id) values(2))' 165 | ), 166 | $e->getMessage(), 167 | ); 168 | } 169 | 170 | $this->assertSame([ 171 | 'SELECT pg_try_advisory_lock(hashtext(?))', 172 | 'insert into users(id) values(1)', 173 | 'SELECT pg_advisory_unlock(hashtext(?))', 174 | ], array_column($conn->getQueryLog(), 'query')); 175 | 176 | $this->assertNotNull( 177 | DB::connection('pgsql2') 178 | ->advisoryLocker() 179 | ->forSession() 180 | ->tryLock('foo'), 181 | ); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /tests/ReconnectionToleranceTest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private array $queries; 24 | private Dispatcher $events; 25 | 26 | /** 27 | * @throws ReflectionException 28 | */ 29 | protected function setUp(): void 30 | { 31 | // Make connections to consider all errors as disconnect errors 32 | eval( 33 | <<<'EOD' 34 | namespace Illuminate\Database; 35 | use Throwable; 36 | trait DetectsLostConnections 37 | { 38 | protected function causedByLostConnection(Throwable $e): bool 39 | { 40 | return true; 41 | } 42 | } 43 | EOD 44 | ); 45 | 46 | parent::setUp(); 47 | 48 | $events = $this->app?->make(Dispatcher::class); 49 | assert($events instanceof Dispatcher); 50 | $this->events = $events; 51 | } 52 | 53 | protected function startListening(): void 54 | { 55 | // Log all prepared queries 56 | $this->events->listen( 57 | StatementPrepared::class, 58 | function (StatementPrepared $event): void { 59 | $this->queries[] = $event->statement->queryString; 60 | }, 61 | ); 62 | } 63 | 64 | protected function endListening(): void 65 | { 66 | $this->events->forget(StatementPrepared::class); 67 | } 68 | 69 | protected function tearDown(): void 70 | { 71 | parent::tearDown(); 72 | 73 | $this->queries = []; 74 | } 75 | 76 | /** 77 | * @dataProvider connectionsMysql 78 | */ 79 | public function testReconnectionWithoutActiveLocks(string $name): void 80 | { 81 | $this->startListening(); 82 | 83 | try { 84 | // MySQL doesn't accept empty locks, so this will trigger QueryException 85 | DB::connection($name) 86 | ->advisoryLocker() 87 | ->forSession() 88 | ->withLocking('', static fn () => null); 89 | } catch (QueryException) { 90 | } 91 | $this->endListening(); 92 | 93 | // Retries 94 | $this->assertSame([ 95 | 'SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)', 96 | 'SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)', 97 | ], $this->queries); 98 | } 99 | 100 | /** 101 | * @dataProvider connectionsMysql 102 | */ 103 | public function testReconnectionWithActiveLocks(string $name): void 104 | { 105 | DB::connection($name) 106 | ->advisoryLocker() 107 | ->forSession() 108 | ->withLocking('foo', function (ConnectionInterface $conn): void { 109 | $this->startListening(); 110 | 111 | try { 112 | // MySQL doesn't accept empty locks, so this will trigger QueryException 113 | $conn 114 | ->advisoryLocker() 115 | ->forSession() 116 | ->withLocking('', static fn () => null); 117 | } catch (QueryException) { 118 | } 119 | $this->endListening(); 120 | }); 121 | 122 | // No retries 123 | $this->assertSame([ 124 | 'SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, 0)', 125 | ], $this->queries); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/SessionLockerTest.php: -------------------------------------------------------------------------------- 1 | advisoryLocker() 26 | ->forSession() 27 | ->withLocking('foo', static function () use ($name, &$passed): void { 28 | DB::connection("{$name}2") 29 | ->advisoryLocker() 30 | ->forSession() 31 | ->withLocking('bar', static function () use (&$passed): void { 32 | $passed = true; 33 | }); 34 | }); 35 | 36 | $this->assertTrue($passed); 37 | } 38 | 39 | /** 40 | * @dataProvider connectionsAll 41 | */ 42 | public function testSameKeysOnDifferentConnections(string $name): void 43 | { 44 | DB::connection($name) 45 | ->advisoryLocker() 46 | ->forSession() 47 | ->withLocking('foo', function () use ($name, &$passed): void { 48 | $this->expectException(LockFailedException::class); 49 | $this->expectExceptionMessage('Failed to acquire lock: foo'); 50 | 51 | DB::connection("{$name}2") 52 | ->advisoryLocker() 53 | ->forSession() 54 | ->withLocking('foo', static function () use (&$passed): void { 55 | $passed = true; 56 | }); 57 | }); 58 | 59 | $this->fail(); 60 | } 61 | 62 | /** 63 | * @dataProvider connectionsAll 64 | */ 65 | public function testDifferentKeysOnSameConnections(string $name): void 66 | { 67 | $passed = false; 68 | 69 | DB::connection($name) 70 | ->advisoryLocker() 71 | ->forSession() 72 | ->withLocking('foo', static function (ConnectionInterface $conn) use (&$passed): void { 73 | $conn 74 | ->advisoryLocker() 75 | ->forSession() 76 | ->withLocking('bar', static function () use (&$passed): void { 77 | $passed = true; 78 | }); 79 | }); 80 | 81 | $this->assertTrue($passed); 82 | } 83 | 84 | /** 85 | * @dataProvider connectionsAll 86 | */ 87 | public function testSameKeysOnSameConnections(string $name): void 88 | { 89 | $passed = false; 90 | 91 | DB::connection($name) 92 | ->advisoryLocker() 93 | ->forSession() 94 | ->withLocking('foo', static function (ConnectionInterface $conn) use (&$passed): void { 95 | $conn 96 | ->advisoryLocker() 97 | ->forSession() 98 | ->withLocking('foo', static function () use (&$passed): void { 99 | $passed = true; 100 | }); 101 | }); 102 | 103 | $this->assertTrue($passed); 104 | } 105 | 106 | /** 107 | * @dataProvider connectionsMysqlLike 108 | */ 109 | public function testMysqlHashing(string $name): void 110 | { 111 | $key = str_repeat('a', 65); 112 | $passed = false; 113 | 114 | DB::connection($name) 115 | ->advisoryLocker() 116 | ->forSession() 117 | ->withLocking($key, function (ConnectionInterface $conn) use ($key, &$passed): void { 118 | $this->assertTrue( 119 | (bool)(new Selector($conn)) 120 | ->select( 121 | 'SELECT IS_USED_LOCK(?)', 122 | [substr($key, 0, 64 - 40) . sha1($key)], 123 | ), 124 | ); 125 | $passed = true; 126 | }); 127 | 128 | $this->assertTrue($passed); 129 | } 130 | 131 | /** 132 | * @dataProvider connectionsMysqlLike 133 | */ 134 | public function testMysqlHashingMultibyte(string $name): void 135 | { 136 | $key = str_repeat('あ', 65); 137 | $passed = false; 138 | 139 | DB::connection($name) 140 | ->advisoryLocker() 141 | ->forSession() 142 | ->withLocking($key, function (ConnectionInterface $conn) use ($key, &$passed): void { 143 | $this->assertTrue( 144 | (bool)(new Selector($conn)) 145 | ->select( 146 | 'SELECT IS_USED_LOCK(?)', 147 | [mb_substr($key, 0, 64 - 40) . sha1($key)], 148 | ), 149 | ); 150 | $passed = true; 151 | }); 152 | 153 | $this->assertTrue($passed); 154 | } 155 | 156 | /** 157 | * @dataProvider connectionsAll 158 | */ 159 | public function testFiniteTimeoutSuccess(string $name): void 160 | { 161 | $proc = self::lockAsync($name, 'foo', 2); 162 | sleep(1); 163 | 164 | try { 165 | $result = DB::connection($name) 166 | ->advisoryLocker() 167 | ->forSession() 168 | ->tryLock('foo', 3); 169 | 170 | $this->assertSame(0, $proc->wait()); 171 | $this->assertNotNull($result); 172 | } finally { 173 | $proc->wait(); 174 | } 175 | } 176 | 177 | /** 178 | * @dataProvider connectionsAll 179 | */ 180 | public function testFiniteTimeoutSuccessConsecutive(string $name): void 181 | { 182 | $proc1 = self::lockAsync($name, 'foo', 5); 183 | $proc2 = self::lockAsync($name, 'baz', 5); 184 | sleep(1); 185 | 186 | try { 187 | $conn = DB::connection($name); 188 | $results = [ 189 | $conn->advisoryLocker()->forSession()->tryLock('foo', 1), 190 | $conn->advisoryLocker()->forSession()->tryLock('bar', 1), 191 | $conn->advisoryLocker()->forSession()->tryLock('baz', 1), 192 | $conn->advisoryLocker()->forSession()->tryLock('qux', 1), 193 | ]; 194 | $result_booleans = array_map(static fn ($result) => $result !== null, $results); 195 | $this->assertSame(0, $proc1->wait()); 196 | $this->assertSame(0, $proc2->wait()); 197 | $this->assertSame([false, true, false, true], $result_booleans); 198 | } finally { 199 | $proc1->wait(); 200 | $proc2->wait(); 201 | } 202 | } 203 | 204 | /** 205 | * @dataProvider connectionsAll 206 | */ 207 | public function testFiniteTimeoutExceeded(string $name): void 208 | { 209 | $proc = self::lockAsync($name, 'foo', 3); 210 | sleep(1); 211 | 212 | try { 213 | $result = DB::connection($name) 214 | ->advisoryLocker() 215 | ->forSession() 216 | ->tryLock('foo', 1); 217 | 218 | $this->assertSame(0, $proc->wait()); 219 | $this->assertNull($result); 220 | } finally { 221 | $proc->wait(); 222 | } 223 | } 224 | 225 | /** 226 | * @dataProvider connectionsMysql 227 | * @dataProvider connectionsPostgres 228 | */ 229 | public function testInfiniteTimeoutSuccess(string $name): void 230 | { 231 | $proc = self::lockAsync($name, 'foo', 2); 232 | sleep(1); 233 | 234 | try { 235 | // MariaDB does not accept negative values 236 | $result = DB::connection($name) 237 | ->advisoryLocker() 238 | ->forSession() 239 | ->tryLock('foo', -1); 240 | 241 | $this->assertSame(0, $proc->wait()); 242 | $this->assertNotNull($result); 243 | } finally { 244 | $proc->wait(); 245 | } 246 | } 247 | 248 | /** 249 | * @dataProvider connectionsPostgres 250 | */ 251 | public function testFloatTimeoutSuccess(string $name): void 252 | { 253 | $proc = self::lockAsync($name, 'foo', 2); 254 | usleep(1_800_000); 255 | 256 | try { 257 | $result = DB::connection($name) 258 | ->advisoryLocker() 259 | ->forSession() 260 | ->tryLock('foo', 0.4); 261 | 262 | $this->assertSame(0, $proc->wait()); 263 | $this->assertNotNull($result); 264 | } finally { 265 | $proc->wait(); 266 | } 267 | } 268 | 269 | /** 270 | * @dataProvider connectionsPostgres 271 | */ 272 | public function testFloatTimeoutExceeded(string $name): void 273 | { 274 | $proc = self::lockAsync($name, 'foo', 2); 275 | usleep(1_700_000); 276 | 277 | try { 278 | $result = DB::connection($name) 279 | ->advisoryLocker() 280 | ->forSession() 281 | ->tryLock('foo', 0.1); 282 | 283 | $this->assertSame(0, $proc->wait()); 284 | $this->assertNull($result); 285 | } finally { 286 | $proc->wait(); 287 | } 288 | } 289 | 290 | /** 291 | * @dataProvider connectionsMysqlLike 292 | */ 293 | public function testFloatTimeoutUnsupported(string $name): void 294 | { 295 | $this->expectException(UnsupportedTimeoutPrecisionException::class); 296 | $this->expectExceptionMessage('Float timeout value is not allowed for MySQL/MariaDB: key=foo, timeout=0.1'); 297 | 298 | DB::connection($name) 299 | ->advisoryLocker() 300 | ->forSession() 301 | ->tryLock('foo', 0.1); 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /tests/TableTestCase.php: -------------------------------------------------------------------------------- 1 | dropIfExists('users'); 19 | $schema->create('users', static function (Blueprint $table): void { 20 | $table->unsignedBigInteger('id')->unique(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | config('database.connections.mysql')]); 24 | config([ 25 | 'database.connections.pgsql.host' => getenv('PG_HOST') ?: 'postgres', 26 | 'database.connections.pgsql.port' => getenv('PG_PORT') ?: '5432', 27 | 'database.connections.pgsql.database' => 'testing', 28 | 'database.connections.pgsql.username' => 'testing', 29 | 'database.connections.pgsql.password' => 'testing', 30 | 'database.connections.mysql.host' => getenv('MY_HOST') ?: 'mysql', 31 | 'database.connections.mysql.port' => getenv('MY_PORT') ?: '3306', 32 | 'database.connections.mysql.database' => 'testing', 33 | 'database.connections.mysql.username' => 'testing', 34 | 'database.connections.mysql.password' => 'testing', 35 | 'database.connections.mariadb.host' => getenv('MA_HOST') ?: 'mariadb', 36 | 'database.connections.mariadb.port' => getenv('MA_PORT') ?: '3306', 37 | 'database.connections.mariadb.database' => 'testing', 38 | 'database.connections.mariadb.username' => 'testing', 39 | 'database.connections.mariadb.password' => 'testing', 40 | ]); 41 | config([ 42 | 'database.connections.mysql2' => config('database.connections.mysql'), 43 | 'database.connections.mariadb2' => config('database.connections.mariadb'), 44 | 'database.connections.pgsql2' => config('database.connections.pgsql'), 45 | ]); 46 | } 47 | 48 | public static function connectionsAll(): array 49 | { 50 | return ['postgres' => ['pgsql'], 'mysql' => ['mysql'], 'mariadb' => ['mariadb']]; 51 | } 52 | 53 | public static function connectionsMysql(): array 54 | { 55 | return ['mysql' => ['mysql']]; 56 | } 57 | 58 | public static function connectionsMysqlLike(): array 59 | { 60 | return ['mysql' => ['mysql'], 'mariadb' => ['mariadb']]; 61 | } 62 | 63 | public static function connectionsPostgres(): array 64 | { 65 | return ['postgres' => ['pgsql']]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/TransactionLockerTest.php: -------------------------------------------------------------------------------- 1 | transaction(static function (ConnectionInterface $conn) use ($name, &$passed): void { 26 | $conn 27 | ->advisoryLocker() 28 | ->forTransaction() 29 | ->lockOrFail('foo'); 30 | 31 | DB::connection("{$name}2")->transaction(static function (ConnectionInterface $conn): void { 32 | $conn 33 | ->advisoryLocker() 34 | ->forTransaction() 35 | ->lockOrFail('bar'); 36 | }); 37 | 38 | $passed = true; 39 | }); 40 | 41 | $this->assertTrue($passed); 42 | } 43 | 44 | /** 45 | * @dataProvider connectionsPostgres 46 | * @throws Throwable 47 | */ 48 | public function testSameKeysOnDifferentConnections(string $name): void 49 | { 50 | DB::connection($name)->transaction(function (ConnectionInterface $conn) use ($name): void { 51 | $conn 52 | ->advisoryLocker() 53 | ->forTransaction() 54 | ->lockOrFail('foo'); 55 | 56 | $this->expectException(LockFailedException::class); 57 | $this->expectExceptionMessage('Failed to acquire lock: foo'); 58 | 59 | DB::connection("{$name}2")->transaction(static function (ConnectionInterface $conn): void { 60 | $conn 61 | ->advisoryLocker() 62 | ->forTransaction() 63 | ->lockOrFail('foo'); 64 | }); 65 | }); 66 | 67 | $this->fail(); 68 | } 69 | 70 | /** 71 | * @dataProvider connectionsPostgres 72 | * @throws Throwable 73 | */ 74 | public function testDifferentKeysOnSameConnections(string $name): void 75 | { 76 | $passed = false; 77 | 78 | DB::connection($name)->transaction(static function (ConnectionInterface $conn) use (&$passed): void { 79 | $conn 80 | ->advisoryLocker() 81 | ->forTransaction() 82 | ->lockOrFail('foo'); 83 | 84 | $conn 85 | ->advisoryLocker() 86 | ->forTransaction() 87 | ->lockOrFail('bar'); 88 | 89 | $passed = true; 90 | }); 91 | 92 | $this->assertTrue($passed); 93 | } 94 | 95 | /** 96 | * @dataProvider connectionsPostgres 97 | * @throws Throwable 98 | */ 99 | public function testSameKeysOnSameConnections(string $name): void 100 | { 101 | $passed = false; 102 | 103 | DB::connection($name)->transaction(static function (ConnectionInterface $conn) use (&$passed): void { 104 | $conn 105 | ->advisoryLocker() 106 | ->forTransaction() 107 | ->lockOrFail('foo'); 108 | 109 | $conn 110 | ->advisoryLocker() 111 | ->forTransaction() 112 | ->lockOrFail('foo'); 113 | 114 | $passed = true; 115 | }); 116 | 117 | $this->assertTrue($passed); 118 | } 119 | 120 | /** 121 | * @dataProvider connectionsPostgres 122 | * @throws Throwable 123 | */ 124 | public function testWithoutTransactions(string $name): void 125 | { 126 | $this->expectException(InvalidTransactionLevelException::class); 127 | $this->expectExceptionMessage('There are no transactions'); 128 | 129 | DB::connection($name) 130 | ->advisoryLocker() 131 | ->forTransaction() 132 | ->lockOrFail('foo'); 133 | } 134 | 135 | /** 136 | * @dataProvider connectionsPostgres 137 | * @throws Throwable 138 | */ 139 | public function testFiniteTimeoutSuccess(string $name): void 140 | { 141 | $proc = self::lockAsync($name, 'foo', 2); 142 | sleep(1); 143 | 144 | try { 145 | $result = DB::connection($name)->transaction(static function (ConnectionInterface $conn) { 146 | return $conn->advisoryLocker()->forTransaction()->tryLock('foo', 3); 147 | }); 148 | 149 | $this->assertSame(0, $proc->wait()); 150 | $this->assertTrue($result); 151 | } finally { 152 | $proc->wait(); 153 | } 154 | } 155 | 156 | /** 157 | * @dataProvider connectionsPostgres 158 | * @throws Throwable 159 | */ 160 | public function testFinitePostgresTimeoutSuccessConsecutive(string $name): void 161 | { 162 | $proc1 = self::lockAsync($name, 'foo', 5); 163 | $proc2 = self::lockAsync($name, 'baz', 5); 164 | sleep(1); 165 | 166 | try { 167 | $result = DB::connection($name)->transaction(static function (ConnectionInterface $conn) { 168 | return [ 169 | $conn->advisoryLocker()->forTransaction()->tryLock('foo', 1), 170 | $conn->advisoryLocker()->forTransaction()->tryLock('bar', 1), 171 | $conn->advisoryLocker()->forTransaction()->tryLock('baz', 1), 172 | $conn->advisoryLocker()->forTransaction()->tryLock('qux', 1), 173 | ]; 174 | }); 175 | $this->assertSame(0, $proc1->wait()); 176 | $this->assertSame(0, $proc2->wait()); 177 | $this->assertSame([false, true, false, true], $result); 178 | } finally { 179 | $proc1->wait(); 180 | $proc2->wait(); 181 | } 182 | } 183 | 184 | /** 185 | * @dataProvider connectionsPostgres 186 | * @throws Throwable 187 | */ 188 | public function testFinitePostgresTimeoutExceeded(string $name): void 189 | { 190 | $proc = self::lockAsync($name, 'foo', 3); 191 | sleep(1); 192 | 193 | try { 194 | $result = DB::connection($name)->transaction(static function (ConnectionInterface $conn) { 195 | return $conn->advisoryLocker()->forTransaction()->tryLock('foo', 1); 196 | }); 197 | 198 | $this->assertSame(0, $proc->wait()); 199 | $this->assertFalse($result); 200 | } finally { 201 | $proc->wait(); 202 | } 203 | } 204 | 205 | /** 206 | * @dataProvider connectionsPostgres 207 | * @throws Throwable 208 | */ 209 | public function testInfinitePostgresTimeoutSuccess(string $name): void 210 | { 211 | $proc = self::lockAsync($name, 'foo', 2); 212 | sleep(1); 213 | 214 | try { 215 | $result = DB::connection($name)->transaction(static function (ConnectionInterface $conn) { 216 | return $conn->advisoryLocker()->forTransaction()->tryLock('foo', -1); 217 | }); 218 | 219 | $this->assertSame(0, $proc->wait()); 220 | $this->assertTrue($result); 221 | } finally { 222 | $proc->wait(); 223 | } 224 | } 225 | } 226 | --------------------------------------------------------------------------------