From 00f32e8453b1927ac948e5a14b11118ee0502d8e Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Fri, 24 Oct 2025 17:33:49 +0300 Subject: [PATCH 1/2] 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. --- .php-cs-fixer.cache | 2 +- README.md | 27 +++++++++ ...atabaseTransactionRetryServiceProvider.php | 2 + src/Providers/DbMacroServiceProvider.php | 56 +++++++++++++++++++ tests/Unit/DBTransactionRetryHelperTest.php | 27 +++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/Providers/DbMacroServiceProvider.php diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 04d07c6..3d38600 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/README.md b/README.md index 359b915..1644a4d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Resilient database transactions for Laravel applications that need to gracefully - Structured logs with request metadata, SQL, bindings, connection information, and stack traces written to dated files under `storage/logs/{Y-m-d}`. - Log titles include the exception class and codes, making it easy to see exactly what triggered the retry. - Optional transaction labels and custom log file names for easier traceability across microservices and jobs. +- Convenience `DB::transactionWithRetry` macro on both the facade and individual connections so existing transaction code stays readable. - Laravel package auto-discovery; no manual service provider registration required. ## Installation @@ -58,6 +59,32 @@ $order = Retry::runWithRetry( `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. +### DB Macro Convenience + +Prefer working through the database facade? Call the included `transactionWithRetry` macro and keep identical behaviour and parameters: + +```php +$invoice = DB::transactionWithRetry( + function () use ($payload) { + return Invoice::fromPayload($payload); + }, + maxRetries: 5, + retryDelay: 1, + trxLabel: 'invoice-sync' +); +``` + +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: + +```php +$report = DB::connection('analytics')->transactionWithRetry( + fn () => $builder->lockForUpdate()->selectRaw('count(*) as total')->first(), + trxLabel: 'analytics-rollup' +); +``` + +The macro is registered automatically when the service provider boots, and sets the `tx.label` container binding the same way as the helper. + ### Parameters | Parameter | Default | Description | diff --git a/src/Providers/DatabaseTransactionRetryServiceProvider.php b/src/Providers/DatabaseTransactionRetryServiceProvider.php index 17cc485..582bc9f 100644 --- a/src/Providers/DatabaseTransactionRetryServiceProvider.php +++ b/src/Providers/DatabaseTransactionRetryServiceProvider.php @@ -14,6 +14,8 @@ public function register(): void __DIR__ . '/../../config/database-transaction-retry.php', 'database-transaction-retry' ); + + $this->app->register(DbMacroServiceProvider::class); } /** diff --git a/src/Providers/DbMacroServiceProvider.php b/src/Providers/DbMacroServiceProvider.php new file mode 100644 index 0000000..fc5080c --- /dev/null +++ b/src/Providers/DbMacroServiceProvider.php @@ -0,0 +1,56 @@ +registerDbFacadeMacro(); + } + + protected function registerDbFacadeMacro(): void + { + $macro = function ( + Closure $callback, + ?int $maxRetries = null, + ?int $retryDelay = null, + ?string $logFileName = null, + string $trxLabel = '' + ) { + return TransactionRetrier::runWithRetry( + $callback, + $maxRetries, + $retryDelay, + $logFileName, + $trxLabel + ); + }; + + if (is_callable([DB::class, 'macro']) && ! DB::hasMacro('transactionWithRetry')) { + DB::macro('transactionWithRetry', $macro); + } + + if ( + method_exists(Connection::class, 'macro') + && method_exists(Connection::class, 'hasMacro') + && ! Connection::hasMacro('transactionWithRetry') + ) { + Connection::macro('transactionWithRetry', $macro); + } + } +} diff --git a/tests/Unit/DBTransactionRetryHelperTest.php b/tests/Unit/DBTransactionRetryHelperTest.php index ad43e30..35275dc 100644 --- a/tests/Unit/DBTransactionRetryHelperTest.php +++ b/tests/Unit/DBTransactionRetryHelperTest.php @@ -11,14 +11,19 @@ function sleep(int $seconds): void use DatabaseTransactions\RetryHelper\Console\StartRetryCommand; use DatabaseTransactions\RetryHelper\Console\StopRetryCommand; +use DatabaseTransactions\RetryHelper\Providers\DbMacroServiceProvider; use DatabaseTransactions\RetryHelper\Services\TransactionRetrier; use DatabaseTransactions\RetryHelper\Support\RetryToggle; use Illuminate\Container\Container; use Illuminate\Database\QueryException; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Traits\Macroable; use Psr\Log\AbstractLogger; use Symfony\Component\Console\Tester\CommandTester; beforeEach(function (): void { + FakeDatabaseManager::flushMacros(); + $this->database = new FakeDatabaseManager(); $this->logManager = new FakeLogManager(); @@ -201,6 +206,26 @@ function sleep(int $seconds): void expect($record['context']['driverCode'])->toBe(1213); }); +test('db facade macro delegates to transaction retrier', function (): void { + $provider = new DbMacroServiceProvider($this->app); + $provider->boot(); + + $attempts = 0; + + expect(FakeDatabaseManager::hasMacro('transactionWithRetry'))->toBeTrue(); + + $result = DB::transactionWithRetry(function () use (&$attempts) { + $attempts++; + + return 'macro-done'; + }, trxLabel: 'macro-test'); + + expect($result)->toBe('macro-done'); + expect($attempts)->toBe(1); + expect($this->database->transactionCalls)->toBe(1); + expect($this->app->make('tx.label'))->toBe('macro-test'); +}); + test('does not retry for non deadlock query exception', function (): void { try { TransactionRetrier::runWithRetry(function (): void { @@ -487,6 +512,8 @@ final class CustomRetryException extends \RuntimeException final class FakeDatabaseManager { + use Macroable; + public int $transactionCalls = 0; /** @var list */ public array $statementCalls = []; From 79a216c71327a1d8528042196df3c92d651eb295 Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Fri, 14 Nov 2025 13:54:45 +0300 Subject: [PATCH 2/2] 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. --- README.md | 12 +-- config/database-transaction-retry.php | 17 +--- src/Services/TransactionRetrier.php | 87 ++++++++-------- tests/Unit/DBTransactionRetryHelperTest.php | 106 ++++++++++---------- 4 files changed, 110 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 1644a4d..091d837 100644 --- a/README.md +++ b/README.md @@ -110,23 +110,21 @@ php artisan vendor:publish --tag=database-transaction-retry-config - `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. - `logging.channel` points at any existing Laravel log channel so you can reuse stacks or third-party drivers. - `logging.levels.success` / `logging.levels.failure` let you tune the severity emitted for successful retries and exhausted attempts (defaults: `warning` and `error`). -- `retryable_exceptions.sql_states` lists SQLSTATE codes that should trigger a retry (defaults to `40001`). -- `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. -- `retryable_exceptions.classes` lets you specify fully-qualified exception class names that should always be retried. +- `retry_on_deadlock` toggles the built-in handling for MySQL deadlocks (`1213`). Set `DB_TRANSACTION_RETRY_ON_DEADLOCK=false` to disable it. +- `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. ## Retry Conditions Retries are attempted when the caught exception matches one of the configured conditions: -- `Illuminate\Database\QueryException` with a SQLSTATE listed in `retryable_exceptions.sql_states`. -- `Illuminate\Database\QueryException` with a driver error code listed in `retryable_exceptions.driver_error_codes` (defaults include `1213` deadlocks and `1205` lock wait timeouts). -- Any exception instance whose class appears in `retryable_exceptions.classes`. +- `Illuminate\Database\QueryException` for MySQL deadlocks (`1213`) when `retry_on_deadlock` is enabled (default). +- `Illuminate\Database\QueryException` for MySQL lock wait timeouts (`1205`) when `retry_on_lock_wait_timeout` is enabled. 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. ## Lock Wait Timeout -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. +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. ## Logging Behaviour diff --git a/config/database-transaction-retry.php b/config/database-transaction-retry.php index 458a41f..e0b4a70 100644 --- a/config/database-transaction-retry.php +++ b/config/database-transaction-retry.php @@ -57,24 +57,13 @@ | Retryable Exceptions |-------------------------------------------------------------------------- | - | Configure the database errors that should trigger a retry. SQLSTATE codes - | and driver error codes are checked for `QueryException` instances. You may - | also list additional exception classes to retry on by name. + | Configure the database errors that should trigger a retry. | */ - 'retryable_exceptions' => [ - 'sql_states' => [ - '40001', // Serialization failure - ], - - 'driver_error_codes' => [ - 1213, // MySQL deadlock - // 1205, // MySQL lock wait timeout - ], + 'retry_on_deadlock' => env('DB_TRANSACTION_RETRY_ON_DEADLOCK', true), - 'classes' => [], - ], + 'retry_on_lock_wait_timeout' => env('DB_TRANSACTION_RETRY_ON_LOCK_WAIT_TIMEOUT', false), /* |-------------------------------------------------------------------------- diff --git a/src/Services/TransactionRetrier.php b/src/Services/TransactionRetrier.php index 0913add..726bce5 100644 --- a/src/Services/TransactionRetrier.php +++ b/src/Services/TransactionRetrier.php @@ -67,7 +67,7 @@ public static function runWithRetry( return DB::transaction($callback); } catch (Throwable $exception) { $exceptionCaught = true; - $shouldRetryError = static::shouldRetry($exception); + $shouldRetryError = static::shouldRetry($exception, $config); if ($shouldRetryError) { $attempt++; @@ -100,50 +100,36 @@ public static function runWithRetry( throw new RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.'); } - protected static function shouldRetry(Throwable $throwable): bool + protected static function shouldRetry(Throwable $throwable, array $config): bool { - $config = function_exists('config') ? config('database-transaction-retry.retryable_exceptions', []) : []; - - if (! is_array($config)) { - $config = []; - } - - $retryableClasses = array_filter( - array_map('trim', is_array($config['classes'] ?? null) ? $config['classes'] : []), - static fn ($class) => $class !== '' - ); - - foreach ($retryableClasses as $class) { - if (class_exists($class) && $throwable instanceof $class) { - return true; - } + if (! $throwable instanceof QueryException) { + return false; } - if ($throwable instanceof QueryException) { - return static::isRetryableQueryException($throwable, $config); - } - - return false; + return static::isRetryableQueryException($throwable, $config); } protected static function isRetryableQueryException(QueryException $exception, array $config): bool { - $sqlStates = is_array($config['sql_states'] ?? null) ? $config['sql_states'] : []; - $sqlStates = array_map(static fn ($state) => strtoupper((string) $state), $sqlStates); - - $driverCodes = is_array($config['driver_error_codes'] ?? null) ? $config['driver_error_codes'] : []; - $driverCodes = array_map(static fn ($code) => (int) $code, $driverCodes); - $sqlState = strtoupper((string) $exception->getCode()); $driverErr = is_array($exception->errorInfo ?? null) && isset($exception->errorInfo[1]) ? (int) $exception->errorInfo[1] : null; - if (in_array($sqlState, $sqlStates, true)) { - return true; + $retryDeadlock = static::normalizeBoolean($config['retry_on_deadlock'] ?? true, true); + $retryLockWait = static::normalizeBoolean($config['retry_on_lock_wait_timeout'] ?? false, false); + + if ($retryDeadlock) { + if ($sqlState === '40001') { + return true; + } + + if (! is_null($driverErr) && $driverErr === 1213) { + return true; + } } - if (! is_null($driverErr) && in_array($driverErr, $driverCodes, true)) { + if ($retryLockWait && ! is_null($driverErr) && $driverErr === 1205) { return true; } @@ -339,15 +325,7 @@ protected static function applyLockWaitTimeout(array $config): void protected static function isLockWaitRetryEnabled(array $config): bool { - $retryable = is_array($config['retryable_exceptions'] ?? null) - ? $config['retryable_exceptions'] - : []; - - $driverCodes = is_array($retryable['driver_error_codes'] ?? null) - ? array_map(static fn ($code) => (int) $code, $retryable['driver_error_codes']) - : []; - - return in_array(1205, $driverCodes, true); + return static::normalizeBoolean($config['retry_on_lock_wait_timeout'] ?? false, false); } protected static function exposeTransactionLabel(string $trxLabel): void @@ -362,4 +340,33 @@ protected static function exposeTransactionLabel(string $trxLabel): void app()->instance('tx.label', $trxLabel); } + + protected static function normalizeBoolean(mixed $value, bool $fallback): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $value = strtolower(trim($value)); + + if ($value === '') { + return $fallback; + } + + if (in_array($value, ['false', '0', 'off', 'no'], true)) { + return false; + } + + if (in_array($value, ['true', '1', 'on', 'yes'], true)) { + return true; + } + } + + if (is_numeric($value)) { + return (int) $value !== 0; + } + + return $fallback; + } } diff --git a/tests/Unit/DBTransactionRetryHelperTest.php b/tests/Unit/DBTransactionRetryHelperTest.php index 35275dc..51254c2 100644 --- a/tests/Unit/DBTransactionRetryHelperTest.php +++ b/tests/Unit/DBTransactionRetryHelperTest.php @@ -242,6 +242,32 @@ function sleep(int $seconds): void expect(SleepSpy::$delays)->toBe([]); }); +test('does not retry when deadlock retry disabled', function (): void { + Container::getInstance()->make('config')->set( + 'database-transaction-retry.retry_on_deadlock', + false + ); + + $attempts = 0; + + try { + TransactionRetrier::runWithRetry(function () use (&$attempts): void { + $attempts++; + + throw makeQueryException(1213); + }, maxRetries: 3, retryDelay: 1, trxLabel: 'deadlock-disabled'); + + $this->fail('Expected QueryException was not thrown.'); + } catch (QueryException $th) { + expect($th->errorInfo[1])->toBe(1213); + } + + expect($attempts)->toBe(1); + expect($this->database->transactionCalls)->toBe(1); + expect(SleepSpy::$delays)->toBe([]); + expect($this->logManager->records)->toBe([]); +}); + test('retries on lock wait timeout and applies configured session timeout', function (): void { Container::getInstance()->make('config')->set( 'database-transaction-retry.lock_wait_timeout_seconds', @@ -249,8 +275,8 @@ function sleep(int $seconds): void ); Container::getInstance()->make('config')->set( - 'database-transaction-retry.retryable_exceptions.driver_error_codes', - [1205] + 'database-transaction-retry.retry_on_lock_wait_timeout', + true ); $attempts = 0; @@ -288,13 +314,8 @@ function sleep(int $seconds): void ); Container::getInstance()->make('config')->set( - 'database-transaction-retry.retryable_exceptions.driver_error_codes', - [1213] - ); - - Container::getInstance()->make('config')->set( - 'database-transaction-retry.retryable_exceptions.sql_states', - [] + 'database-transaction-retry.retry_on_lock_wait_timeout', + false ); try { @@ -310,61 +331,26 @@ function sleep(int $seconds): void expect($this->database->statementCalls)->toBe([]); }); -test('retries when driver code is configured', function (): void { - Container::getInstance()->make('config')->set( - 'database-transaction-retry.retryable_exceptions.driver_error_codes', - [1213, 999] - ); - +test('retries when SQLSTATE indicates deadlock even without driver code', function (): void { $attempts = 0; $result = TransactionRetrier::runWithRetry(function () use (&$attempts) { $attempts++; if ($attempts === 1) { - throw makeQueryException(999, 0); - } - - return 'recovered'; - }, maxRetries: 3, retryDelay: 1, trxLabel: 'invoices'); - - expect($result)->toBe('recovered'); - expect($this->database->transactionCalls)->toBe(2); - expect($this->logManager->records)->toHaveCount(1); - $record = $this->logManager->records[0]; - - expect($record['message'])->toBe('[invoices] [DATABASE TRANSACTION RETRY - SUCCESS] Illuminate\Database\QueryException (SQLSTATE 00000, Driver 999) After (Attempts: 1/3) - Warning'); - expect($record['context']['driverCode'])->toBe(999); - expect($record['context']['sqlState'])->toBe('00000'); -}); - -test('retries when exception class is configured', function (): void { - Container::getInstance()->make('config')->set( - 'database-transaction-retry.retryable_exceptions.classes', - [CustomRetryException::class] - ); - - $attempts = 0; - - $result = TransactionRetrier::runWithRetry(function () use (&$attempts) { - $attempts++; - - if ($attempts === 1) { - throw new CustomRetryException('try again'); + throw makeSqlStateOnlyQueryException('40001'); } return 'ok'; - }, maxRetries: 3, retryDelay: 1, trxLabel: 'custom'); + }, maxRetries: 3, retryDelay: 1, trxLabel: 'sqlstate-deadlock'); expect($result)->toBe('ok'); expect($this->database->transactionCalls)->toBe(2); - + expect($this->logManager->records)->toHaveCount(1); $record = $this->logManager->records[0]; - expect($record['message'])->toBe('[custom] [DATABASE TRANSACTION RETRY - SUCCESS] Tests\\CustomRetryException After (Attempts: 1/3) - Warning'); - expect($record['context']['exceptionClass'])->toBe(CustomRetryException::class); - expect(array_key_exists('driverCode', $record['context']))->toBeFalse(); - expect(array_key_exists('sqlState', $record['context']))->toBeFalse(); + expect($record['context']['sqlState'])->toBe('40001'); + expect($record['context']['driverCode'])->toBeNull(); }); test('binds transaction label into container during execution', function (): void { @@ -506,8 +492,26 @@ function makeQueryException(int $driverCode, string|int $sqlState = 40001): Quer ); } -final class CustomRetryException extends \RuntimeException +function makeSqlStateOnlyQueryException(string|int $sqlState = 40001): QueryException { + $sqlStateString = strtoupper((string) $sqlState); + + if (strlen($sqlStateString) < 5) { + $sqlStateString = str_pad($sqlStateString, 5, '0', STR_PAD_LEFT); + } + + $pdo = new \PDOException( + 'SQLSTATE[' . $sqlStateString . ']: Driver error', + is_numeric($sqlState) ? (int) $sqlState : 0 + ); + $pdo->errorInfo = [$sqlStateString, null, 'Driver error']; + + return new QueryException( + 'mysql', + 'insert into foo (bar) values (?)', + ['baz'], + $pdo + ); } final class FakeDatabaseManager