Skip to content

Commit b573b04

Browse files
committed
Adds lock wait timeout retry and configuration
Adds the ability to retry transactions that fail due to lock wait timeouts. Introduces a configuration option to set the session-level lock wait timeout before each transaction attempt. This helps to prevent indefinite waiting and ensures a predictable timeout, even after reconnections or pool reuse. Updates the documentation to reflect the new functionality and configuration options.
1 parent 302ffed commit b573b04

File tree

6 files changed

+175
-12
lines changed

6 files changed

+175
-12
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ vendor/
44
.idea/
55
.vscode/
66
.DS_Store
7+
8+
.php-cs-fixer.cache

.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\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"01b810fda87e28b849389c952b6141e9","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"149e31651f0e22a15a7df90c9edb5baa","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"7202d36b32e4d5c70cc19d83392cdda1","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}}
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\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"7d99d773c44861e3f12524a5119f5240","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"149e31651f0e22a15a7df90c9edb5baa","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"07bb6b8e6c8b3ce61a7e67f128f12f4a","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}}

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Resilient database transactions for Laravel applications that need to gracefully
2222

2323
## Highlights
2424

25-
- Retries known transient failures out of the box (SQLSTATE `40001`, MySQL driver error `1213`), and lets you add extra SQLSTATE codes, driver error codes, or exception classes through configuration.
25+
- Retries known transient failures out of the box (SQLSTATE `40001`, MySQL driver errors `1213` and `1205`), and lets you add extra SQLSTATE codes, driver error codes, or exception classes through configuration.
2626
- Exponential backoff with jitter between attempts to reduce stampedes under load.
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.
@@ -77,29 +77,33 @@ Publish the configuration file to tweak defaults globally:
7777
php artisan vendor:publish --tag=database-transaction-retry-config
7878
```
7979

80-
Key options (`config/database-transaction-retry.php`):
80+
- Key options (`config/database-transaction-retry.php`):
8181

8282
- `max_retries`, `retry_delay`, and `log_file_name` set the package-wide defaults when you omit parameters. Each respects environment variables (`DB_TRANSACTION_RETRY_MAX_RETRIES`, `DB_TRANSACTION_RETRY_DELAY`, `DB_TRANSACTION_RETRY_LOG_FILE`).
83+
- `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.
8384
- `logging.channel` points at any existing Laravel log channel so you can reuse stacks or third-party drivers.
84-
- `logging.config` provides a full configuration array for `Log::build()` when you want a dedicated writer.
8585
- `logging.levels.success` / `logging.levels.failure` let you tune the severity emitted for successful retries and exhausted attempts (defaults: `warning` and `error`).
8686
- `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`).
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.
8888
- `retryable_exceptions.classes` lets you specify fully-qualified exception class names that should always be retried.
8989

9090
## Retry Conditions
9191

9292
Retries are attempted when the caught exception matches one of the configured conditions:
9393

9494
- `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`.
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).
9696
- Any exception instance whose class appears in `retryable_exceptions.classes`.
9797

9898
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.
9999

100+
## Lock Wait Timeout
101+
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.
103+
100104
## Logging Behaviour
101105

102-
By default, logs are written using a dedicated single-file channel per day. Override `logging.channel` or `logging.config` to integrate with your own logging stack:
106+
By default, logs are written using a dedicated single-file channel per day. Override `logging.channel` to integrate with your own logging stack:
103107

104108
- Success after retries → a warning entry titled `"[trxLabel] [DATABASE TRANSACTION RETRY - SUCCESS] ExceptionClass (Codes) After (Attempts: x/y) - Warning"`.
105109
- Failure after exhausting retries → an error entry titled `"[trxLabel] [DATABASE TRANSACTION RETRY - FAILED] ExceptionClass (Codes) After (Attempts: x/y) - Error"`.

config/database-transaction-retry.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@
1616

1717
'retry_delay' => (int) env('DB_TRANSACTION_RETRY_DELAY', 2),
1818

19+
/*
20+
|--------------------------------------------------------------------------
21+
| Lock Wait Timeout
22+
|--------------------------------------------------------------------------
23+
|
24+
| Optionally override the session-level lock wait timeout before executing
25+
| the transaction. When set to a positive integer the helper issues:
26+
| "SET SESSION innodb_lock_wait_timeout = {seconds}" on the active
27+
| connection prior to each attempt. Set to null to leave the database
28+
| default untouched.
29+
|
30+
*/
31+
32+
'lock_wait_timeout_seconds' => env('DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT', 50),
33+
1934
'log_file_name' => env('DB_TRANSACTION_RETRY_LOG_FILE', 'database/transaction-retries'),
2035

2136
/*
@@ -34,8 +49,6 @@
3449
'logging' => [
3550
'channel' => env('DB_TRANSACTION_RETRY_LOG_CHANNEL'),
3651

37-
'config' => null,
38-
3952
'levels' => [
4053
'success' => env('DB_TRANSACTION_RETRY_LOG_SUCCESS_LEVEL', 'warning'),
4154
'failure' => env('DB_TRANSACTION_RETRY_LOG_FAILURE_LEVEL', 'error'),
@@ -60,6 +73,7 @@
6073

6174
'driver_error_codes' => [
6275
1213, // MySQL deadlock
76+
//1205, // MySQL lock wait timeout
6377
],
6478

6579
'classes' => [],

src/Services/TransactionRetrier.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ public static function runWithRetry(
5050
$shouldRetryError = false;
5151

5252
try {
53+
static::applyLockWaitTimeout($config);
54+
5355
// Expose the transaction label if the app wants to read it during the callback.
5456
$trxLabel === '' || app()->instance('tx.label', $trxLabel);
5557

@@ -296,4 +298,46 @@ protected static function normalizeLogLevel(?string $level, string $fallback): s
296298

297299
return $candidate !== '' ? $candidate : $fallback;
298300
}
301+
302+
protected static function applyLockWaitTimeout(array $config): void
303+
{
304+
$seconds = $config['lock_wait_timeout_seconds'] ?? null;
305+
306+
if (! static::isLockWaitRetryEnabled($config)) {
307+
return;
308+
}
309+
310+
if (is_null($seconds)) {
311+
return;
312+
}
313+
314+
if (is_string($seconds) && $seconds === '') {
315+
return;
316+
}
317+
318+
$seconds = (int) $seconds;
319+
320+
if ($seconds < 1) {
321+
return;
322+
}
323+
324+
try {
325+
DB::statement('SET SESSION innodb_lock_wait_timeout = ?', [$seconds]);
326+
} catch (Throwable) {
327+
// Silently ignore when the underlying driver does not support this option.
328+
}
329+
}
330+
331+
protected static function isLockWaitRetryEnabled(array $config): bool
332+
{
333+
$retryable = is_array($config['retryable_exceptions'] ?? null)
334+
? $config['retryable_exceptions']
335+
: [];
336+
337+
$driverCodes = is_array($retryable['driver_error_codes'] ?? null)
338+
? array_map(static fn ($code) => (int) $code, $retryable['driver_error_codes'])
339+
: [];
340+
341+
return in_array(1205, $driverCodes, true);
342+
}
299343
}

tests/Unit/DBTransactionRetryHelperTest.php

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,74 @@ function sleep(int $seconds): void
110110
expect(SleepSpy::$delays)->toBe([]);
111111
});
112112

113+
test('retries on lock wait timeout and applies configured session timeout', function (): void {
114+
Container::getInstance()->make('config')->set(
115+
'database-transaction-retry.lock_wait_timeout_seconds',
116+
7
117+
);
118+
119+
Container::getInstance()->make('config')->set(
120+
'database-transaction-retry.retryable_exceptions.driver_error_codes',
121+
[1205]
122+
);
123+
124+
$attempts = 0;
125+
126+
$result = TransactionRetrier::runWithRetry(function () use (&$attempts) {
127+
$attempts++;
128+
129+
if ($attempts === 1) {
130+
throw makeQueryException(1205, 'HY000');
131+
}
132+
133+
return 'done';
134+
}, maxRetries: 3, retryDelay: 1, trxLabel: 'lock-wait');
135+
136+
expect($result)->toBe('done');
137+
expect($this->database->transactionCalls)->toBe(2);
138+
expect(SleepSpy::$delays)->toHaveCount(1);
139+
expect($this->database->statementCalls)->toHaveCount(2);
140+
141+
[$statement, $bindings] = $this->database->statementCalls[0];
142+
expect($statement)->toBe('SET SESSION innodb_lock_wait_timeout = ?');
143+
expect($bindings)->toBe([7]);
144+
145+
$record = $this->logManager->records[0];
146+
expect($record['context']['driverCode'])->toBe(1205);
147+
expect($record['context']['sqlState'])->toBe('HY000');
148+
expect($record['message'])->toContain('Driver 1205');
149+
expect($record['message'])->toContain('SQLSTATE HY000');
150+
});
151+
152+
test('does not change session timeout when lock wait retry disabled', function (): void {
153+
Container::getInstance()->make('config')->set(
154+
'database-transaction-retry.lock_wait_timeout_seconds',
155+
9
156+
);
157+
158+
Container::getInstance()->make('config')->set(
159+
'database-transaction-retry.retryable_exceptions.driver_error_codes',
160+
[1213]
161+
);
162+
163+
Container::getInstance()->make('config')->set(
164+
'database-transaction-retry.retryable_exceptions.sql_states',
165+
[]
166+
);
167+
168+
try {
169+
TransactionRetrier::runWithRetry(function (): void {
170+
throw makeQueryException(1205, 'HY000');
171+
}, maxRetries: 2, retryDelay: 1);
172+
173+
$this->fail('Expected QueryException was not thrown.');
174+
} catch (QueryException $th) {
175+
expect($th->errorInfo[1])->toBe(1205);
176+
}
177+
178+
expect($this->database->statementCalls)->toBe([]);
179+
});
180+
113181
test('retries when driver code is configured', function (): void {
114182
Container::getInstance()->make('config')->set(
115183
'database-transaction-retry.retryable_exceptions.driver_error_codes',
@@ -167,10 +235,18 @@ function sleep(int $seconds): void
167235
expect(array_key_exists('sqlState', $record['context']))->toBeFalse();
168236
});
169237

170-
function makeQueryException(int $driverCode, int $sqlState = 40001): QueryException
238+
function makeQueryException(int $driverCode, string|int $sqlState = 40001): QueryException
171239
{
172-
$sqlStateString = str_pad((string) $sqlState, 5, '0', STR_PAD_LEFT);
173-
$pdo = new \PDOException('SQLSTATE[' . $sqlStateString . ']: Driver error', $sqlState);
240+
$sqlStateString = strtoupper((string) $sqlState);
241+
242+
if (strlen($sqlStateString) < 5) {
243+
$sqlStateString = str_pad($sqlStateString, 5, '0', STR_PAD_LEFT);
244+
}
245+
246+
$pdo = new \PDOException(
247+
'SQLSTATE[' . $sqlStateString . ']: Driver error',
248+
is_numeric($sqlState) ? (int) $sqlState : 0
249+
);
174250
$pdo->errorInfo = [$sqlStateString, $driverCode, 'Driver error'];
175251

176252
return new QueryException(
@@ -188,6 +264,8 @@ final class CustomRetryException extends \RuntimeException
188264
final class FakeDatabaseManager
189265
{
190266
public int $transactionCalls = 0;
267+
/** @var list<array{0:string,1:array}> */
268+
public array $statementCalls = [];
191269
private FakeConnection $connection;
192270

193271
public function __construct(?FakeConnection $connection = null)
@@ -206,11 +284,25 @@ public function connection(?string $name = null): FakeConnection
206284
{
207285
return $this->connection;
208286
}
287+
288+
public function statement(string $query, array $bindings = []): bool
289+
{
290+
$this->statementCalls[] = [$query, $bindings];
291+
292+
return $this->connection()->statement($query, $bindings);
293+
}
294+
295+
public function getConnection(): FakeConnection
296+
{
297+
return $this->connection;
298+
}
209299
}
210300

211301
final class FakeConnection
212302
{
213303
private FakeQueryGrammar $grammar;
304+
/** @var list<array{0:string,1:array}> */
305+
public array $statements = [];
214306

215307
public function __construct(?FakeQueryGrammar $grammar = null)
216308
{
@@ -221,6 +313,13 @@ public function getQueryGrammar(): FakeQueryGrammar
221313
{
222314
return $this->grammar;
223315
}
316+
317+
public function statement(string $query, array $bindings = []): bool
318+
{
319+
$this->statements[] = [$query, $bindings];
320+
321+
return true;
322+
}
224323
}
225324

226325
final class FakeQueryGrammar

0 commit comments

Comments
 (0)