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..091d837 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 | @@ -83,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/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/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 ad43e30..51254c2 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 { @@ -217,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', @@ -224,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; @@ -263,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 { @@ -285,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] - ); - - $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] - ); - +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 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 { @@ -481,12 +492,32 @@ 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 { + use Macroable; + public int $transactionCalls = 0; /** @var list */ public array $statementCalls = [];