├── .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 [](https://github.com/mpyw/laravel-database-advisory-lock/actions) [](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 |
--------------------------------------------------------------------------------