Skip to content

Commit 05a73a3

Browse files
committed
Merge branch 'main' into develop
2 parents 81e71e8 + 5e024c6 commit 05a73a3

File tree

14 files changed

+1909
-681
lines changed

14 files changed

+1909
-681
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
name: CI
22

33
on:
4+
push:
5+
branches: [ develop, main ]
46
pull_request:
5-
branches: [ master ]
7+
branches: [ develop, main ]
68

79
jobs:
8-
phpunit:
9-
name: PHPUnit
10+
pest:
11+
name: Pest
1012
runs-on: ubuntu-latest
1113
strategy:
1214
matrix:
@@ -33,8 +35,8 @@ jobs:
3335
- name: Install dependencies
3436
run: composer install --no-progress --prefer-dist --no-interaction
3537

36-
- name: Run PHPUnit
37-
run: composer test
38+
- name: Run Pest
39+
run: ./vendor/bin/pest --ci
3840

3941
php-cs-fixer:
4042
name: PHP CS Fixer

.php-cs-fixer.cache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"php":"8.4.13","version":"3.88.2:v3.88.2#a8d15584bafb0f0d9d938827840060fd4a3ebc99","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"src\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9"}}
1+
{"php":"8.2.29","version":"3.88.2:v3.88.2#a8d15584bafb0f0d9d938827840060fd4a3ebc99","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","src\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","tests\/TestCase.php":"6df2b13208f4952f10b306fad99e1c51","tests\/bootstrap.php":"8af7490a2832c4cce20f0980636bad41","tests\/DBTransactionRetryHelperTest.php":"5e9993c586d9318449b2181ece54bc73","\/tmp\/PHP CS Fixertemp_folder\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder1\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20",".php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder2\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder10\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder4\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder5\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder11\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder9\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder815\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder8\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder3\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder7\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder6\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder1\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"38a42cae2dcaf6fa55519bec4b64e252","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}}

.php-cs-fixer.php

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
$finder = PhpCsFixer\Finder::create()
66
->in([
77
__DIR__ . '/src',
8+
__DIR__ . '/tests',
89
])
910
->name('*.php')
1011
->ignoreVCS(true);
@@ -14,19 +15,19 @@
1415
->setRules([
1516
'@PSR12' => true,
1617
// 'declare_strict_types' => true,
17-
'no_extra_blank_lines' => true,
18-
'array_syntax' => ['syntax' => 'short'],
19-
'single_quote' => true,
20-
'no_unused_imports' => true,
21-
'ordered_imports' => ['sort_algorithm' => 'alpha'],
22-
'no_superfluous_phpdoc_tags' => true,
23-
'phpdoc_trim' => true,
24-
'phpdoc_align' => ['align' => 'left'],
25-
'binary_operator_spaces' => ['default' => 'align_single_space_minimal'],
18+
'no_extra_blank_lines' => true,
19+
'array_syntax' => ['syntax' => 'short'],
20+
'single_quote' => true,
21+
'no_unused_imports' => true,
22+
'ordered_imports' => ['sort_algorithm' => 'alpha'],
23+
'no_superfluous_phpdoc_tags' => true,
24+
'phpdoc_trim' => true,
25+
'phpdoc_align' => ['align' => 'left'],
26+
'binary_operator_spaces' => ['default' => 'align_single_space_minimal'],
2627
'blank_line_before_statement' => ['statements' => ['return']],
2728
'no_whitespace_in_blank_line' => true,
28-
'simplified_null_return' => true,
29-
'void_return' => true,
29+
'simplified_null_return' => true,
30+
'void_return' => true,
3031
])
3132
->setFinder($finder)
32-
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect());
33+
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect());

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Ahed Wakim
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.

README.md

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,120 @@
1-
# laravel-mysql-deadlock-retry
1+
<p align="center">
2+
<img src="art/logo.svg" width="250" alt="MySQL Deadlock Retry Helper">
3+
</p>
24

3-
A lightweight helper to run Laravel database transactions with automatic retries on MySQL deadlocks and serialization failures.
5+
<p align="center">
6+
<a href="https://github.com/Ahed92Wakim/laravel-mysql-deadlock-retry/actions/workflows/ci.yml">
7+
<img src="https://github.com/Ahed92Wakim/laravel-mysql-deadlock-retry/actions/workflows/ci.yml/badge.svg?branch=main" alt="Tests">
8+
</a>
9+
<a href="https://packagist.org/packages/ahed92wakim/laravel-mysql-deadlock-retry">
10+
<img src="https://img.shields.io/packagist/v/ahed92wakim/laravel-mysql-deadlock-retry.svg" alt="Packagist Version">
11+
</a>
12+
<a href="LICENSE">
13+
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License">
14+
</a>
15+
<img src="https://img.shields.io/badge/Laravel-%5E11-red.svg" alt="Laravel ^11">
16+
<img src="https://img.shields.io/badge/PHP-%5E8.2-blue.svg" alt="PHP ^8.2">
17+
<img src="https://img.shields.io/badge/style-PHP%20CS%20Fixer-informational.svg" alt="PHP CS Fixer">
18+
</p>
419

5-
Features:
6-
- Retries DB::transaction on MySQL deadlocks (error 1213) and SQLSTATE 40001
7-
- Exponential backoff with jitter between attempts
8-
- Structured logging per attempt to storage/logs
9-
- Safe in HTTP, CLI and queue contexts (request info captured when available)
10-
- Transaction labeling for easier debugging
11-
- Enhanced logging with SQL query information
1220

13-
Installation:
14-
- Require the package via Composer: `composer require ahed92wakim/laravel-mysql-deadlock-retry`
21+
Resilient database transactions for Laravel applications that need to gracefully handle MySQL deadlocks and serialization failures. This helper wraps `DB::transaction()` with targeted retries, structured logging, and exponential backoff so you can keep your business logic simple while surviving transient contention.
1522

16-
Usage:
23+
## Highlights
24+
25+
- Retries only known transient failure scenarios (MySQL driver error `1213` and SQLSTATE `40001`), leaving all other exceptions untouched.
26+
- Exponential backoff with jitter between attempts to reduce stampedes under load.
27+
- Structured logs with request metadata, SQL, bindings, connection information, and stack traces written to dated files under `storage/logs/{Y-m-d}`.
28+
- Safe in HTTP, CLI, and queue contexts: request data is collected when available and ignored when not.
29+
- Optional transaction labels and custom log file names for easier traceability across microservices and jobs.
30+
- Laravel package auto-discovery; no manual service provider registration required.
31+
32+
## Installation
33+
34+
```bash
35+
composer require ahed92wakim/laravel-mysql-deadlock-retry
36+
```
37+
38+
The package ships with a service provider that is auto-discovered. No additional setup is needed, and the helper functions in `src/Helper.php` are automatically loaded.
39+
40+
## Usage
1741

1842
```php
1943
use MysqlDeadlocks\RetryHelper\DBTransactionRetryHelper as Retry;
2044

21-
$result = Retry::transactionWithRetry(function () {
22-
// Your DB logic here (queries, models, etc.)
23-
// Return any value and it will be returned from transactionWithRetry
24-
}, maxRetries: 3, retryDelay: 2, logFileName: 'mysql-deadlocks', trxLabel: 'user-update');
45+
$order = Retry::transactionWithRetry(
46+
function () use ($payload) {
47+
$order = Order::create($payload);
48+
$order->logAuditTrail();
49+
50+
return $order;
51+
},
52+
maxRetries: 4,
53+
retryDelay: 1,
54+
logFileName: 'mysql-deadlocks/orders',
55+
trxLabel: 'order-create'
56+
);
57+
```
58+
59+
`transactionWithRetry()` returns the value produced by your callback, just like `DB::transaction()`. If every attempt fails, the last `QueryException` is re-thrown so your calling code can continue its normal error handling.
60+
61+
### Parameters
62+
63+
| Parameter | Default | Description |
64+
| ------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------- |
65+
| `maxRetries` | `3` | Total number of attempts (initial try + retries). |
66+
| `retryDelay` | `2` | Base delay (seconds). Actual wait uses exponential backoff with ±25% jitter. |
67+
| `logFileName` | `database/mysql-deadlocks` | Written to `storage/logs/{Y-m-d}/{logFileName}.log`. Can point to subdirectories. |
68+
| `trxLabel` | `''` | Optional label injected into log titles and stored in the service container as `tx.label` for downstream consumers. |
69+
70+
Call the helper anywhere you would normally open a transaction—controllers, jobs, console commands, or domain services.
71+
72+
## Retry Conditions
73+
74+
Retries are attempted only when the caught exception is an `Illuminate\Database\QueryException` that matches one of:
75+
76+
- SQLSTATE `40001` (serialization failure).
77+
- MySQL driver error `1213` (deadlock), whether reported via SQLSTATE or the driver error code.
78+
79+
Everything else (e.g., constraint violations, syntax errors, driver error `1205`, application exceptions) is surfaced immediately without logging or sleeping.
80+
81+
If no attempt succeeds and all retries are exhausted, the last `QueryException` is re-thrown. In the rare case nothing is thrown but the loop exits, a `RuntimeException` is raised to signal exhaustion.
82+
83+
## Logging Behaviour
84+
85+
Logs are written using a dedicated single-file channel per day:
86+
87+
- Success after retries → a warning entry titled `"[trxLabel] [MYSQL DEADLOCK RETRY - SUCCESS] After (Attempts: x/y) - Warning"`.
88+
- Failure after exhausting retries → an error entry titled `"[trxLabel] [MYSQL DEADLOCK RETRY - FAILED] After (Attempts: x/y) - Error"`.
89+
90+
Each log entry includes:
91+
92+
- Attempt count, maximum retries, and transaction label.
93+
- Connection name, SQL, resolved raw SQL (when bindings are available), and PDO error info.
94+
- A compacted stack trace and sanitized bindings.
95+
- Request URL, method, authorization header length, and authenticated user ID when the request helper is bound.
96+
97+
Set `logFileName` to segment logs by feature or workload (e.g., `logFileName: 'database/queues/payments'`).
98+
99+
## Testing the Package
100+
101+
Run the test suite with:
102+
103+
```bash
104+
composer test
25105
```
26106

27-
Parameters:
28-
- maxRetries: number of attempts (default 3)
29-
- retryDelay: base delay in seconds; actual wait uses exponential backoff with jitter (default 2)
30-
- logFileName: file prefix under storage/logs/{today date} (default 'database/mysql-deadlocks')
31-
- trxLabel: transaction label for easier identification in logs (default '')
32-
33-
Logging:
34-
- Logs are stored in storage/logs/{date}/ directory
35-
- Successful transactions after retries are logged as warnings
36-
- Failed transactions after all retries are logged as errors
37-
- Logs include SQL queries, stack traces, and request information when available
38-
39-
Notes:
40-
- Non-deadlock QueryException is thrown immediately.
41-
- When attempts are exhausted, the last QueryException is thrown; if somehow no exception was thrown, a RuntimeException is raised.
42-
- Requires PHP 8.2+ and Laravel 11.0+
107+
Tests cover the retry flow, logging behaviour, exponential backoff jitter, and non-deadlock scenarios using fakes for the database and logger managers.
108+
109+
## Requirements
110+
111+
- PHP `>= 8.2`
112+
- Laravel `>= 11.0`
113+
114+
## Contributing
115+
116+
Bugs, ideas, and pull requests are welcome. Feel free to open an issue describing the problem or improvement before submitting a PR so we can collaborate on scope.
117+
118+
## License
119+
120+
This package is open-sourced software released under the MIT License.

art/logo.svg

Lines changed: 3 additions & 0 deletions
Loading

composer.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,18 @@
3636
],
3737
"require-dev": {
3838
"friendsofphp/php-cs-fixer": "^3.88",
39-
"phpunit/phpunit": "^12.4"
39+
"phpunit/phpunit": "^11.5",
40+
"pestphp/pest": "^3.8"
4041
},
4142
"scripts": {
4243
"fix": "php-cs-fixer fix --config=.php-cs-fixer.php",
4344
"fix:dry": "php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff",
4445
"fix:ci": "php-cs-fixer fix --config=.php-cs-fixer.php --using-cache=no --dry-run",
45-
"test": "vendor/bin/phpunit --configuration phpunit.xml --colors=always"
46+
"test": "vendor/bin/pest --configuration phpunit.xml --colors=always"
47+
},
48+
"config": {
49+
"allow-plugins": {
50+
"pestphp/pest-plugin": true
51+
}
4652
}
4753
}

0 commit comments

Comments
 (0)