Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: CI

on:
push:
branches: [ develop, main ]
pull_request:
branches: [ master ]
branches: [ develop, main ]

jobs:
phpunit:
Expand Down
2 changes: 1 addition & 1 deletion .php-cs-fixer.cache
Original file line number Diff line number Diff line change
@@ -1 +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"}}
{"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"}}
124 changes: 92 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,102 @@
# laravel-mysql-deadlock-retry
# Laravel MySQL Deadlock Retry

A lightweight helper to run Laravel database transactions with automatic retries on MySQL deadlocks and serialization failures.
![CI](https://github.com/Ahed92Wakim/laravel-mysql-deadlock-retry/actions/workflows/ci.yml/badge.svg?event=pull_request)
![PHP](https://img.shields.io/badge/php-8.2-blue)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Features:
- Retries DB::transaction on MySQL deadlocks (error 1213) and SQLSTATE 40001
- Exponential backoff with jitter between attempts
- Structured logging per attempt to storage/logs
- Safe in HTTP, CLI and queue contexts (request info captured when available)
- Transaction labeling for easier debugging
- Enhanced logging with SQL query information
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.

Installation:
- Require the package via Composer: `composer require ahed92wakim/laravel-mysql-deadlock-retry`
## Highlights
- Retries only known transient failure scenarios (MySQL driver error `1213` and SQLSTATE `40001`), leaving all other exceptions untouched.
- Exponential backoff with jitter between attempts to reduce stampedes under load.
- Structured logs with request metadata, SQL, bindings, connection information, and stack traces written to dated files under `storage/logs/{Y-m-d}`.
- Safe in HTTP, CLI, and queue contexts: request data is collected when available and ignored when not.
- Optional transaction labels and custom log file names for easier traceability across microservices and jobs.
- Laravel package auto-discovery; no manual service provider registration required.

Usage:
## Installation

```bash
composer require ahed92wakim/laravel-mysql-deadlock-retry
```

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.

## Usage

```php
use MysqlDeadlocks\RetryHelper\DBTransactionRetryHelper as Retry;

$result = Retry::transactionWithRetry(function () {
// Your DB logic here (queries, models, etc.)
// Return any value and it will be returned from transactionWithRetry
}, maxRetries: 3, retryDelay: 2, logFileName: 'mysql-deadlocks', trxLabel: 'user-update');
$order = Retry::transactionWithRetry(
function () use ($payload) {
$order = Order::create($payload);
$order->logAuditTrail();

return $order;
},
maxRetries: 4,
retryDelay: 1,
logFileName: 'mysql-deadlocks/orders',
trxLabel: 'order-create'
);
```

Parameters:
- maxRetries: number of attempts (default 3)
- retryDelay: base delay in seconds; actual wait uses exponential backoff with jitter (default 2)
- logFileName: file prefix under storage/logs/{today date} (default 'database/mysql-deadlocks')
- trxLabel: transaction label for easier identification in logs (default '')

Logging:
- Logs are stored in storage/logs/{date}/ directory
- Successful transactions after retries are logged as warnings
- Failed transactions after all retries are logged as errors
- Logs include SQL queries, stack traces, and request information when available

Notes:
- Non-deadlock QueryException is thrown immediately.
- When attempts are exhausted, the last QueryException is thrown; if somehow no exception was thrown, a RuntimeException is raised.
- Requires PHP 8.2+ and Laravel 11.0+
`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.

### Parameters

| Parameter | Default | Description |
| --- | --- | --- |
| `maxRetries` | `3` | Total number of attempts (initial try + retries). |
| `retryDelay` | `2` | Base delay (seconds). Actual wait uses exponential backoff with ±25% jitter. |
| `logFileName` | `database/mysql-deadlocks` | Written to `storage/logs/{Y-m-d}/{logFileName}.log`. Can point to subdirectories. |
| `trxLabel` | `''` | Optional label injected into log titles and stored in the service container as `tx.label` for downstream consumers. |

Call the helper anywhere you would normally open a transaction—controllers, jobs, console commands, or domain services.

## Retry Conditions

Retries are attempted only when the caught exception is an `Illuminate\Database\QueryException` that matches one of:
- SQLSTATE `40001` (serialization failure).
- MySQL driver error `1213` (deadlock), whether reported via SQLSTATE or the driver error code.

Everything else (e.g., constraint violations, syntax errors, driver error `1205`, application exceptions) is surfaced immediately without logging or sleeping.

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.

## Logging Behaviour

Logs are written using a dedicated single-file channel per day:
- Success after retries → a warning entry titled `"[trxLabel] [MYSQL DEADLOCK RETRY - SUCCESS] After (Attempts: x/y) - Warning"`.
- Failure after exhausting retries → an error entry titled `"[trxLabel] [MYSQL DEADLOCK RETRY - FAILED] After (Attempts: x/y) - Error"`.

Each log entry includes:
- Attempt count, maximum retries, and transaction label.
- Connection name, SQL, resolved raw SQL (when bindings are available), and PDO error info.
- A compacted stack trace and sanitized bindings.
- Request URL, method, authorization header length, and authenticated user ID when the request helper is bound.

Set `logFileName` to segment logs by feature or workload (e.g., `logFileName: 'database/queues/payments'`).

## Testing the Package

Run the test suite with:

```bash
composer test
```

Tests cover the retry flow, logging behaviour, exponential backoff jitter, and non-deadlock scenarios using fakes for the database and logger managers.

## Requirements

- PHP `>= 8.2`
- Laravel `>= 11.0`

## Contributing

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.

## License

This package is open-sourced software released under the MIT License.