Skip to content

Commit 33806a9

Browse files
authored
Simplifies retry configuration for deadlocks and timeouts (#14)
* Adds DB facade macro for transaction retries Introduces a `DB::transactionWithRetry` macro to the DB facade and connection instances. This allows for easier and more readable integration of transaction retry logic directly through the database facade, simplifying existing transaction code. Includes documentation updates and necessary service provider registration. * Simplifies retry configuration for deadlocks and timeouts Replaces complex, multi-faceted retryable exceptions configuration with boolean flags for deadlock and lock wait timeout retries. This change simplifies configuration and improves readability. The configuration now uses `retry_on_deadlock` and `retry_on_lock_wait_timeout` flags.
1 parent cfef3ff commit 33806a9

File tree

7 files changed

+223
-113
lines changed

7 files changed

+223
-113
lines changed

.php-cs-fixer.cache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","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":{"tests\/Unit\/DBTransactionRetryHelperTest.php":"27f37268e9ae100d356ca1c06c756616","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Console\/StartRetryCommand.php":"b0e6c76186a59d1341fa6600e096f9a3","src\/Console\/StopRetryCommand.php":"ad3d7cbb9841006db54f12168c58d369","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"638c2f84c78a86c8e8c5e4daef07c46e","src\/Services\/TransactionRetrier.php":"504c45ab8315c7b3d60a64e0a52759af","src\/Support\/RetryToggle.php":"2516f4c290940019b1f1c069fec64be8","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"897cfbd81822f4b71075ccb1739df70d","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087"}}
1+
{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","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":{"tests\/Unit\/DBTransactionRetryHelperTest.php":"22e94a33db107726ebdb9ab6c1794f38","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Console\/StartRetryCommand.php":"ebbff074a2e7d79f377ef520285c6109","src\/Console\/StopRetryCommand.php":"92a0540c45489bf3a6ba11c517b84699","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"a4ba3a51cad9c3a470246518bc1bf5a6","src\/Services\/TransactionRetrier.php":"504c45ab8315c7b3d60a64e0a52759af","src\/Support\/RetryToggle.php":"2516f4c290940019b1f1c069fec64be8","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"897cfbd81822f4b71075ccb1739df70d","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","src\/Providers\/DbMacroServiceProvider.php":"e17026a164c34320845575f00076938d"}}

README.md

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Resilient database transactions for Laravel applications that need to gracefully
2727
- Structured logs with request metadata, SQL, bindings, connection information, and stack traces written to dated files under `storage/logs/{Y-m-d}`.
2828
- Log titles include the exception class and codes, making it easy to see exactly what triggered the retry.
2929
- Optional transaction labels and custom log file names for easier traceability across microservices and jobs.
30+
- Convenience `DB::transactionWithRetry` macro on both the facade and individual connections so existing transaction code stays readable.
3031
- Laravel package auto-discovery; no manual service provider registration required.
3132

3233
## Installation
@@ -58,6 +59,32 @@ $order = Retry::runWithRetry(
5859

5960
`runWithRetry()` returns the value produced by your callback, just like `DB::transaction()`. If every attempt fails, the last exception is re-thrown so your calling code can continue its normal error handling.
6061

62+
### DB Macro Convenience
63+
64+
Prefer working through the database facade? Call the included `transactionWithRetry` macro and keep identical behaviour and parameters:
65+
66+
```php
67+
$invoice = DB::transactionWithRetry(
68+
function () use ($payload) {
69+
return Invoice::fromPayload($payload);
70+
},
71+
maxRetries: 5,
72+
retryDelay: 1,
73+
trxLabel: 'invoice-sync'
74+
);
75+
```
76+
77+
Need connection-specific logic? Because the macro is applied to `Illuminate\Support\Facades\DB` **and** to every resolved `Illuminate\Database\Connection`, you can call it on connection instances as well:
78+
79+
```php
80+
$report = DB::connection('analytics')->transactionWithRetry(
81+
fn () => $builder->lockForUpdate()->selectRaw('count(*) as total')->first(),
82+
trxLabel: 'analytics-rollup'
83+
);
84+
```
85+
86+
The macro is registered automatically when the service provider boots, and sets the `tx.label` container binding the same way as the helper.
87+
6188
### Parameters
6289

6390
| Parameter | Default | Description |
@@ -83,23 +110,21 @@ php artisan vendor:publish --tag=database-transaction-retry-config
83110
- `lock_wait_timeout_seconds` lets you override `innodb_lock_wait_timeout` per attempt; set the matching environment variable (`DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT`) to control the session value or leave null to use the database default.
84111
- `logging.channel` points at any existing Laravel log channel so you can reuse stacks or third-party drivers.
85112
- `logging.levels.success` / `logging.levels.failure` let you tune the severity emitted for successful retries and exhausted attempts (defaults: `warning` and `error`).
86-
- `retryable_exceptions.sql_states` lists SQLSTATE codes that should trigger a retry (defaults to `40001`).
87-
- `retryable_exceptions.driver_error_codes` lists driver-specific error codes (defaults to `1213` deadlocks and `1205` lock wait timeouts). Including `1205` not only enables retries but also activates the optional session lock wait timeout override when configured.
88-
- `retryable_exceptions.classes` lets you specify fully-qualified exception class names that should always be retried.
113+
- `retry_on_deadlock` toggles the built-in handling for MySQL deadlocks (`1213`). Set `DB_TRANSACTION_RETRY_ON_DEADLOCK=false` to disable it.
114+
- `retry_on_lock_wait_timeout` toggles retries for MySQL lock wait timeouts (`1205`) **and** activates the optional session timeout override. Set `DB_TRANSACTION_RETRY_ON_LOCK_WAIT_TIMEOUT=true` to enable it.
89115

90116
## Retry Conditions
91117

92118
Retries are attempted when the caught exception matches one of the configured conditions:
93119

94-
- `Illuminate\Database\QueryException` with a SQLSTATE listed in `retryable_exceptions.sql_states`.
95-
- `Illuminate\Database\QueryException` with a driver error code listed in `retryable_exceptions.driver_error_codes` (defaults include `1213` deadlocks and `1205` lock wait timeouts).
96-
- Any exception instance whose class appears in `retryable_exceptions.classes`.
120+
- `Illuminate\Database\QueryException` for MySQL deadlocks (`1213`) when `retry_on_deadlock` is enabled (default).
121+
- `Illuminate\Database\QueryException` for MySQL lock wait timeouts (`1205`) when `retry_on_lock_wait_timeout` is enabled.
97122

98123
Everything else (e.g., constraint violations, syntax errors, application exceptions) is surfaced immediately without logging or sleeping. If no attempt succeeds and all retries are exhausted, the last exception is re-thrown. In the rare case nothing is thrown but the loop exits, a `RuntimeException` is raised to signal exhaustion.
99124

100125
## Lock Wait Timeout
101126

102-
When `lock_wait_timeout_seconds` is configured, the retrier issues `SET SESSION innodb_lock_wait_timeout = {seconds}` on the active connection before each attempt, but only when the retry rules include the lock-wait timeout driver code (`1205`). This keeps the timeout predictable even after reconnects or pool reuse, and on drivers that do not support the statement the helper safely ignores the failure.
127+
When `lock_wait_timeout_seconds` is configured, the retrier issues `SET SESSION innodb_lock_wait_timeout = {seconds}` on the active connection before each attempt, but only when `retry_on_lock_wait_timeout` is enabled. This keeps the timeout predictable even after reconnects or pool reuse, and on drivers that do not support the statement the helper safely ignores the failure.
103128

104129
## Logging Behaviour
105130

config/database-transaction-retry.php

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,24 +57,13 @@
5757
| Retryable Exceptions
5858
|--------------------------------------------------------------------------
5959
|
60-
| Configure the database errors that should trigger a retry. SQLSTATE codes
61-
| and driver error codes are checked for `QueryException` instances. You may
62-
| also list additional exception classes to retry on by name.
60+
| Configure the database errors that should trigger a retry.
6361
|
6462
*/
6563

66-
'retryable_exceptions' => [
67-
'sql_states' => [
68-
'40001', // Serialization failure
69-
],
70-
71-
'driver_error_codes' => [
72-
1213, // MySQL deadlock
73-
// 1205, // MySQL lock wait timeout
74-
],
64+
'retry_on_deadlock' => env('DB_TRANSACTION_RETRY_ON_DEADLOCK', true),
7565

76-
'classes' => [],
77-
],
66+
'retry_on_lock_wait_timeout' => env('DB_TRANSACTION_RETRY_ON_LOCK_WAIT_TIMEOUT', false),
7867

7968
/*
8069
|--------------------------------------------------------------------------

src/Providers/DatabaseTransactionRetryServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public function register(): void
1414
__DIR__ . '/../../config/database-transaction-retry.php',
1515
'database-transaction-retry'
1616
);
17+
18+
$this->app->register(DbMacroServiceProvider::class);
1719
}
1820

1921
/**
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace DatabaseTransactions\RetryHelper\Providers;
4+
5+
use Closure;
6+
use DatabaseTransactions\RetryHelper\Services\TransactionRetrier;
7+
use Illuminate\Database\Connection;
8+
use Illuminate\Support\Facades\DB;
9+
use Illuminate\Support\ServiceProvider;
10+
11+
class DbMacroServiceProvider extends ServiceProvider
12+
{
13+
public function register(): void
14+
{
15+
//
16+
}
17+
18+
/**
19+
* Bootstrap any package services.
20+
*/
21+
public function boot(): void
22+
{
23+
$this->registerDbFacadeMacro();
24+
}
25+
26+
protected function registerDbFacadeMacro(): void
27+
{
28+
$macro = function (
29+
Closure $callback,
30+
?int $maxRetries = null,
31+
?int $retryDelay = null,
32+
?string $logFileName = null,
33+
string $trxLabel = ''
34+
) {
35+
return TransactionRetrier::runWithRetry(
36+
$callback,
37+
$maxRetries,
38+
$retryDelay,
39+
$logFileName,
40+
$trxLabel
41+
);
42+
};
43+
44+
if (is_callable([DB::class, 'macro']) && ! DB::hasMacro('transactionWithRetry')) {
45+
DB::macro('transactionWithRetry', $macro);
46+
}
47+
48+
if (
49+
method_exists(Connection::class, 'macro')
50+
&& method_exists(Connection::class, 'hasMacro')
51+
&& ! Connection::hasMacro('transactionWithRetry')
52+
) {
53+
Connection::macro('transactionWithRetry', $macro);
54+
}
55+
}
56+
}

0 commit comments

Comments
 (0)