From 0a65f632233541b89e9a22e1e8427f37303df7dc Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Fri, 17 Oct 2025 15:51:19 +0300 Subject: [PATCH 1/2] Refactors transaction retry logic into services Moves retry logic into dedicated service classes for better organization and testability. Removes the global helper functions and related service provider in favor of a dedicated service. Exposes support classes for logging, tracing, and binding stringification to facilitate reuse and testing. --- .php-cs-fixer.cache | 2 +- README.md | 18 +- composer.json | 7 +- src/DBTransactionRetryHelper.php | 164 ----------- src/DBTransactionRetryHelperOld.php | 258 ------------------ src/Helper.php | 83 ------ .../DatabaseRetryServiceProvider.php | 21 ++ src/RetryServiceProvider.php | 24 -- src/Services/DeadlockTransactionRetrier.php | 210 ++++++++++++++ src/Support/BindingStringifier.php | 39 +++ src/Support/DeadlockLogWriter.php | 38 +++ src/Support/TraceFormatter.php | 26 ++ tests/Unit/DBTransactionRetryHelperTest.php | 10 +- 13 files changed, 356 insertions(+), 544 deletions(-) delete mode 100644 src/DBTransactionRetryHelper.php delete mode 100644 src/DBTransactionRetryHelperOld.php delete mode 100644 src/Helper.php create mode 100644 src/Providers/DatabaseRetryServiceProvider.php delete mode 100644 src/RetryServiceProvider.php create mode 100644 src/Services/DeadlockTransactionRetrier.php create mode 100644 src/Support/BindingStringifier.php create mode 100644 src/Support/DeadlockLogWriter.php create mode 100644 src/Support/TraceFormatter.php diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index baeb063..20a72e1 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.2.29","version":"3.88.2:v3.88.2#a8d15584bafb0f0d9d938827840060fd4a3ebc99","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","src\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","tests\/TestCase.php":"6df2b13208f4952f10b306fad99e1c51","tests\/bootstrap.php":"8af7490a2832c4cce20f0980636bad41","tests\/DBTransactionRetryHelperTest.php":"5e9993c586d9318449b2181ece54bc73","\/tmp\/PHP CS Fixertemp_folder\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder1\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20",".php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder2\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder10\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder4\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder5\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder11\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder9\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder815\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder8\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder3\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder7\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder6\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder1\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"38a42cae2dcaf6fa55519bec4b64e252","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}} \ No newline at end of file +{"php":"8.2.29","version":"3.88.2:v3.88.2#a8d15584bafb0f0d9d938827840060fd4a3ebc99","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","src\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","tests\/TestCase.php":"6df2b13208f4952f10b306fad99e1c51","tests\/bootstrap.php":"8af7490a2832c4cce20f0980636bad41","tests\/DBTransactionRetryHelperTest.php":"5e9993c586d9318449b2181ece54bc73","\/tmp\/PHP CS Fixertemp_folder\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder1\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20",".php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder2\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder10\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder4\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder5\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder11\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder9\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder815\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder8\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder3\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder7\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder6\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder1\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"40363cf626a0e00d5bacad08e35e194d","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","src\/Providers\/DatabaseRetryServiceProvider.php":"8872d0bf1c662e68b70c591e2c987359","src\/Services\/DeadlockTransactionRetrier.php":"b41008b1b4d2cfde2df03a4f164ef44d","src\/Support\/DeadlockLogWriter.php":"9e26ef11c8d2311ab2752a3192a13612","src\/Support\/BindingStringifier.php":"3aa21139dad20340d9518fa57e0845ca","src\/Support\/TraceFormatter.php":"13f19f8c9de611faa05847ae3890b73d"}} \ No newline at end of file diff --git a/README.md b/README.md index d70be76..41d2466 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,14 @@ Resilient database transactions for Laravel applications that need to gracefully composer require ahed92wakim/laravel-mysql-deadlock-retry ``` -The package ships with a service provider that is auto-discovered. No additional setup is needed, and the helper functions in `src/Helper.php` are automatically loaded. +The package ships with the `DatabaseRetryServiceProvider`, which Laravel auto-discovers. No additional setup is needed. ## Usage ```php -use MysqlDeadlocks\RetryHelper\DBTransactionRetryHelper as Retry; +use MysqlDeadlocks\RetryHelper\Services\DeadlockTransactionRetrier as Retry; -$order = Retry::transactionWithRetry( +$order = Retry::runWithRetry( function () use ($payload) { $order = Order::create($payload); $order->logAuditTrail(); @@ -56,7 +56,7 @@ $order = Retry::transactionWithRetry( ); ``` -`transactionWithRetry()` returns the value produced by your callback, just like `DB::transaction()`. If every attempt fails, the last `QueryException` is re-thrown so your calling code can continue its normal error handling. +`runWithRetry()` returns the value produced by your callback, just like `DB::transaction()`. If every attempt fails, the last `QueryException` is re-thrown so your calling code can continue its normal error handling. ### Parameters @@ -96,6 +96,16 @@ Each log entry includes: Set `logFileName` to segment logs by feature or workload (e.g., `logFileName: 'database/queues/payments'`). +## Helper Utilities + +The package exposes dedicated support classes you can reuse in your own instrumentation: + +- `MysqlDeadlocks\RetryHelper\Support\DeadlockLogWriter` writes structured entries using the same format as the retrier. +- `MysqlDeadlocks\RetryHelper\Support\TraceFormatter` converts debug backtraces into log-friendly arrays. +- `MysqlDeadlocks\RetryHelper\Support\BindingStringifier` sanitises query bindings before logging. + +For testing scenarios, the retrier looks for a namespaced `MysqlDeadlocks\RetryHelper\sleep()` function before falling back to PHP's global `sleep()`, making it easy to assert backoff intervals without introducing delays. + ## Testing the Package Run the test suite with: diff --git a/composer.json b/composer.json index 32de44a..dca505b 100644 --- a/composer.json +++ b/composer.json @@ -11,10 +11,7 @@ "autoload": { "psr-4": { "MysqlDeadlocks\\RetryHelper\\": "src/" - }, - "files": [ - "src/Helper.php" - ] + } }, "autoload-dev": { "psr-4": { @@ -24,7 +21,7 @@ "extra": { "laravel": { "providers": [ - "MysqlDeadlocks\\RetryHelper\\RetryServiceProvider" + "MysqlDeadlocks\\RetryHelper\\Providers\\DatabaseRetryServiceProvider" ] } }, diff --git a/src/DBTransactionRetryHelper.php b/src/DBTransactionRetryHelper.php deleted file mode 100644 index 5ec2ab5..0000000 --- a/src/DBTransactionRetryHelper.php +++ /dev/null @@ -1,164 +0,0 @@ -instance('tx.label', $trxLabel); - $result = DB::transaction($callback); - - return $result; - - } catch (QueryException $e) { - $exceptionCatched = true; - - // Only deadlock/serialization *may* be retried and logged - $isDeadlock = static::isDeadlockOrSerializationError($e); - - if ($isDeadlock) { - $attempt++; - $log[] = static::buildLogContext($e, $attempt, $maxRetries, $trxLabel); - - if ($attempt >= $maxRetries) { - // exhausted retries — throw after logging below in finally - $throwable = $e; - } else { - // Exponential backoff with jitter - $delay = static::backoffDelay($retryDelay, $attempt); - sleep($delay); - continue; // retry - } - } else { - // Non-deadlock: DO NOT log, just rethrow - $throwable = $e; - } - } finally { - if (is_null($throwable) && !$exceptionCatched) { - // Success on first try, nothing to do. - // If you want to warn when there WERE previous retries that succeeded, keep this block: - if (count($log) > 0) { - // optional: downgrade to warning for eventual success after retries - generateLog($log[count($log) - 1], $logFileName, 'warning'); - } - } elseif (!is_null($throwable)) { - // We only log when it is a DEADLOCK and retries are exhausted. - if ($isDeadlock && count($log) > 0) { - generateLog($log[count($log) - 1], $logFileName); - } - - // For NON-deadlock, nothing is logged — just throw. - throw $throwable; - } - } - } - - throw new \RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.'); - } - - protected static function isDeadlockOrSerializationError(QueryException $e): bool - { - // MySQL deadlock: driver error 1213; lock wait timeout: 1205 (often not retryable); SQLSTATE 40001 serialization failure - $sqlState = $e->getCode(); // In Laravel, getCode often returns SQLSTATE (e.g., '40001') - $driverErr = is_array($e->errorInfo ?? null) && isset($e->errorInfo[1]) ? $e->errorInfo[1] : null; - - return ($sqlState === '40001') - || ($driverErr === 1213) - || ($sqlState === 1213) // in case driver bubbles numeric - ; - } - - protected static function buildLogContext(QueryException $e, int $attempt, int $maxRetries, string $trxLabel): array - { - // Extract sql & bindings safely - $sql = method_exists($e, 'getSql') ? $e->getSql() : null; - $bindings = method_exists($e, 'getBindings') ? $e->getBindings() : []; - - $connectionName = $e->getConnectionName(); - $conn = DB::connection($connectionName); - - // if laravel version <= 11.x then getRawSql() is not available and we will do it manually - $rawSql = method_exists($e, 'getRawSql') ? $e->getRawSql() : null; - if (is_null($rawSql) && !is_null($sql) && !empty($bindings)) { - $rawSql = $conn->getQueryGrammar()->substituteBindingsIntoRawSql($sql, $bindings); - } - - $requestData = [ - 'url' => null, - 'method' => null, - 'token' => null, - 'userId' => null, - ]; - - try { - if (function_exists('request') && app()->bound('request')) { - $req = request(); - $requestData['url'] = method_exists($req, 'getUri') ? $req->getUri() : null; - $requestData['method'] = method_exists($req, 'getMethod') ? $req->getMethod() : null; - if (method_exists($req, 'header')) { - $auth = $req->header('authorization'); - $requestData['authHeaderLen'] = $auth ? strlen($auth) : null; - } - $requestData['userId'] = method_exists($req, 'user') && $req->user() ? ($req->user()->id ?? null) : null; - } - } catch (Throwable) { - // ignore - } - - return array_merge($requestData, [ - 'attempt' => $attempt, - 'maxRetries' => $maxRetries, - 'trxLabel' => $trxLabel, - 'errorInfo' => $e->errorInfo, - 'rawSql' => $rawSql, - 'connection' => $connectionName, - 'trace' => getDebugBacktraceArray(), - ]); - } - - /** - * @throws RandomException - */ - protected static function backoffDelay(int $baseDelay, int $attempt): int - { - // Simple exponential backoff with jitter: baseDelay * 2^(attempt-1) +/- 25% - $delay = max(1, (int)round($baseDelay * pow(2, max(0, $attempt - 1)))); - $jitter = max(0, (int)round($delay * 0.25)); - $min = max(1, $delay - $jitter); - $max = $delay + $jitter; - - return random_int($min, $max); - } -} diff --git a/src/DBTransactionRetryHelperOld.php b/src/DBTransactionRetryHelperOld.php deleted file mode 100644 index 4af7384..0000000 --- a/src/DBTransactionRetryHelperOld.php +++ /dev/null @@ -1,258 +0,0 @@ - 'retrying'], $ctx) -// ); -// -// if ($attempt >= $maxRetries) { -// // --------------------------- -// // Case 2: exhausted retries -// // --------------------------- -// static::logWithChannel( -// $logFileName, -// 'error', -// "[$txLabel] DB Deadlock; Retries Exhausted After (Attempts: $attempt/$maxRetries)", -// array_merge(['result' => 'failed'], $ctx) -// ); -// throw $e; -// } -// -// // Exponential backoff with jitter -// $delay = static::backoffDelay($retryDelay, $attempt); -// sleep($delay); -// continue; // next loop -// } -// -// // --------------------------- -// // Case 3: not a deadlock -// // --------------------------- -// static::logWithChannel( -// $logFileName, -// 'error', -// "[$txLabel] DB non-Deadlock Error; (Attempts: $attempt/$maxRetries)", -// array_merge(['result' => 'non-deadlock'], $ctx) -// ); -// -// throw $e; // propagate -// -// } catch (Throwable $e) { -// // Non-QueryException: log basic info & rethrow -// $attempt++; -// static::logWithChannel( -// $logFileName, -// 'error', -// "[$txLabel] DB Threw non-QueryException; (Attempts: $attempt/$maxRetries)", -// [ -// 'attempt' => $attempt, -// 'maxRetries' => $maxRetries, -// 'exception' => get_class($e), -// 'message' => $e->getMessage(), -// 'trace' => static::safeTrace(), -// ] -// ); -// throw $e; -// } -// } -// -// // Should not reach here because we either return or throw inside the loop -// throw new \RuntimeException("[$txLabel] Transaction with retry exhausted after: (attempts: $attempt/$maxRetries)"); -// } -// -// protected static function isDeadlockOrSerializationError(QueryException $e): bool -// { -// // MySQL: 1213 = deadlock; 40001 = serialization failure (SQLSTATE) -// // We intentionally DO NOT include 1205 (lock wait timeout) — treat as non-deadlock. -// $sqlState = $e->getCode(); // Often SQLSTATE like '40001' -// $driverErr = is_array($e->errorInfo ?? null) && isset($e->errorInfo[1]) ? $e->errorInfo[1] : null; -// -// return ($sqlState === '40001') -// || ($driverErr === 1213) -// || ($sqlState === 1213); -// } -// -// protected static function buildLogContext(QueryException $e, int $attempt): array -// { -// // Extract sql & bindings safely -// $sql = method_exists($e, 'getSql') ? $e->getSql() : null; -// $bindings = method_exists($e, 'getBindings') ? $e->getBindings() : []; -// -// // Try to read connection name -// $connectionName = null; -// try { -// $connection = DB::connection(); -// $connectionName = $connection?->getName(); -// } catch (Throwable) { -// // ignore -// } -// -// $requestData = [ -// 'url' => null, -// 'method' => null, -// 'authHeaderLen' => null, // don't log sensitive tokens -// 'userId' => null, -// ]; -// -// try { -// if (function_exists('request') && app()->bound('request')) { -// $req = request(); -// $requestData['url'] = method_exists($req, 'getUri') ? $req->getUri() : null; -// $requestData['method'] = method_exists($req, 'getMethod') ? $req->getMethod() : null; -// if (method_exists($req, 'header')) { -// $auth = $req->header('authorization'); -// $requestData['authHeaderLen'] = $auth ? strlen($auth) : null; -// } -// $requestData['userId'] = method_exists($req, 'user') && $req->user() ? ($req->user()->id ?? null) : null; -// } -// } catch (Throwable) { -// // ignore -// } -// -// return array_merge($requestData, [ -// 'attempt' => $attempt, -// 'Exception' => get_class($e), -// 'message' => $e->getMessage(), -// 'sql' => $sql, -// 'bindings' => static::stringifyBindings($bindings), -// 'errorInfo' => $e->errorInfo, -// 'connection' => $connectionName, -// 'trace' => static::safeTrace(), -// ]); -// } -// -// protected static function stringifyBindings(array $bindings): array -// { -//// return array_map(function ($b) { -//// if ($b instanceof \DateTimeInterface) { -//// return $b->format('Y-m-d H:i:s.u'); -//// } -//// if (is_object($b) || is_array($b)) { -//// return json_encode($b, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); -//// } -//// return $b; -//// }, $bindings); -// return array_map(function ($b) { -// if ($b instanceof \DateTimeInterface) { -// return $b->format('Y-m-d H:i:s.u'); -// } -// if (is_object($b)) { -// return '[object ' . get_class($b) . ']'; -// } -// if (is_resource($b)) { -// return '[resource]'; -// } -// if (is_string($b)) { -// // Trim very long strings to avoid log bloat -// return mb_strlen($b) > 500 ? (mb_substr($b, 0, 500) . '…[+trimmed]') : $b; -// } -// if (is_array($b)) { -// // Compact arrays -// $json = @json_encode($b, JSON_UNESCAPED_UNICODE); -// -// return $json !== false -// ? (mb_strlen($json) > 500 ? (mb_substr($json, 0, 500) . '…[+trimmed]') : $json) -// : '[array]'; -// } -// -// return $b; -// }, $bindings); -// } -// -// protected static function safeTrace(): array -// { -// try { -// return collect(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)) -// ->map(fn($f) => [ -// 'file' => $f['file'] ?? null, -// 'line' => $f['line'] ?? null, -// 'function' => $f['function'] ?? null, -// 'class' => $f['class'] ?? null, -// 'type' => $f['type'] ?? null, -// ])->all(); -// } catch (Throwable) { -// return []; -// } -// } -// -// /** -// * @throws RandomException -// */ -// protected static function backoffDelay(int $baseDelay, int $attempt): int -// { -// // Simple exponential backoff with jitter: baseDelay * 2^(attempt-1) +/- 25% -// $delay = max(1, (int)round($baseDelay * pow(2, max(0, $attempt - 1)))); -// $jitter = max(0, (int)round($delay * 0.25)); -// $min = max(1, $delay - $jitter); -// $max = $delay + $jitter; -// return random_int($min, $max); -// } -// -// protected static function logWithChannel(string $channel, string $level, string $message, array $context = []): void -// { -// $logger = Log::build([ -// 'driver' => 'single', -// 'path' => storage_path("logs/{$channel}.log"), -// ]); -// -// // Normalize level -// $level = strtolower($level); -// if (!in_array($level, ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug'], true)) { -// $level = 'info'; -// } -// -// $logger->{$level}($message, $context); -// } -//} diff --git a/src/Helper.php b/src/Helper.php deleted file mode 100644 index 59db03f..0000000 --- a/src/Helper.php +++ /dev/null @@ -1,83 +0,0 @@ -map(fn ($f) => [ - 'file' => $f['file'] ?? null, - 'line' => $f['line'] ?? null, - 'function' => $f['function'] ?? null, - 'class' => $f['class'] ?? null, - 'type' => $f['type'] ?? null, - ])->all(); - } catch (Throwable) { - return []; - } - } -} - -if (! function_exists('stringifyBindings')) { - function stringifyBindings(array $bindings): array - { - return array_map(function ($b) { - if ($b instanceof \DateTimeInterface) { - return $b->format('Y-m-d H:i:s.u'); - } - if (is_object($b)) { - return '[object '.get_class($b).']'; - } - if (is_resource($b)) { - return '[resource]'; - } - if (is_string($b)) { - // Trim very long strings to avoid log bloat - return mb_strlen($b) > 500 ? (mb_substr($b, 0, 500).'…[+trimmed]') : $b; - } - if (is_array($b)) { - // Compact arrays - $json = @json_encode($b, JSON_UNESCAPED_UNICODE); - - return $json !== false - ? (mb_strlen($json) > 500 ? (mb_substr($json, 0, 500).'…[+trimmed]') : $json) - : '[array]'; - } - - return $b; - }, $bindings); - } -} - -if (!function_exists('generateLog')) { - function generateLog($var, $logFileName, $logType = 'error'): void - { - $date = function_exists('now') ? now()->toDateString() : date('Y-m-d'); - - if (empty($logFileName)) { - $logFilePath = storage_path('logs/' . $date . '/general.log'); - } else { - $logFilePath = storage_path('logs/' . $date . "/{$logFileName}.log"); - } - $log = Log::build([ - 'driver' => 'single', - 'path' => $logFilePath, - ]); - $payload = is_array($var) ? $var : ['message' => (string)$var]; - $attempts = $var['attempt'] ?? 0; - $maxRetries = $var['maxRetries'] ?? 0; - $trxLabel = $var['trxLabel'] ?? ''; - - if ($logType === 'warning') { - // Transaction succeeded after retries - $title = "[$trxLabel] [MYSQL DEADLOCK RETRY - SUCCESS] After (Attempts: $attempts/$maxRetries) - Warning"; - $log->warning($title, $payload); - } else { - // Transaction failed after all attempts - $title = "[$trxLabel] [MYSQL DEADLOCK RETRY - FAILED] After (Attempts: $attempts/$maxRetries) - Error"; - $log->error($title, $payload); - } - } -} diff --git a/src/Providers/DatabaseRetryServiceProvider.php b/src/Providers/DatabaseRetryServiceProvider.php new file mode 100644 index 0000000..31b6073 --- /dev/null +++ b/src/Providers/DatabaseRetryServiceProvider.php @@ -0,0 +1,21 @@ +instance('tx.label', $trxLabel); + + return DB::transaction($callback); + } catch (QueryException $e) { + $exceptionCaught = true; + $shouldRetryError = static::shouldRetry($e); + + if ($shouldRetryError) { + $attempt++; + $logEntries[] = static::makeRetryContext($e, $attempt, $maxRetries, $trxLabel); + + if ($attempt >= $maxRetries) { + $throwable = $e; + } else { + static::pause(static::nextBackoffInterval($retryDelay, $attempt)); + continue; + } + } else { + $throwable = $e; + } + } finally { + static::logOutcome( + $logEntries, + $logFileName, + $throwable, + $exceptionCaught, + $shouldRetryError + ); + + if (! is_null($throwable)) { + throw $throwable; + } + } + } + + throw new RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.'); + } + + /** + * @deprecated Use runWithRetry() instead. + */ + public static function transactionWithRetry( + Closure $callback, + int $maxRetries = 3, + int $retryDelay = 2, + string $logFileName = 'database/mysql-deadlocks', + string $trxLabel = '' + ): mixed { + return static::runWithRetry($callback, $maxRetries, $retryDelay, $logFileName, $trxLabel); + } + + protected static function shouldRetry(QueryException $e): bool + { + return static::isDeadlock($e) || static::isSerializationFailure($e); + } + + protected static function isDeadlock(QueryException $e): bool + { + $driverErr = is_array($e->errorInfo ?? null) && isset($e->errorInfo[1]) ? $e->errorInfo[1] : null; + $sqlState = $e->getCode(); + + return (int) $driverErr === 1213 || (int) $sqlState === 1213; + } + + protected static function isSerializationFailure(QueryException $e): bool + { + return $e->getCode() === '40001'; + } + + protected static function makeRetryContext(QueryException $e, int $attempt, int $maxRetries, string $trxLabel): array + { + $sql = method_exists($e, 'getSql') ? $e->getSql() : null; + $bindings = method_exists($e, 'getBindings') ? $e->getBindings() : []; + + $connectionName = $e->getConnectionName(); + $conn = DB::connection($connectionName); + + $rawSql = method_exists($e, 'getRawSql') ? $e->getRawSql() : null; + if (is_null($rawSql) && ! is_null($sql) && ! empty($bindings)) { + $rawSql = $conn->getQueryGrammar()->substituteBindingsIntoRawSql($sql, $bindings); + } + + $requestData = [ + 'url' => null, + 'method' => null, + 'token' => null, + 'userId' => null, + ]; + + try { + if (function_exists('request') && app()->bound('request')) { + $req = request(); + $requestData['url'] = method_exists($req, 'getUri') ? $req->getUri() : null; + $requestData['method'] = method_exists($req, 'getMethod') ? $req->getMethod() : null; + if (method_exists($req, 'header')) { + $auth = $req->header('authorization'); + $requestData['authHeaderLen'] = $auth ? strlen($auth) : null; + } + $requestData['userId'] = method_exists($req, 'user') && $req->user() + ? ($req->user()->id ?? null) + : null; + } + } catch (Throwable) { + // ignore + } + + return array_merge($requestData, [ + 'attempt' => $attempt, + 'maxRetries' => $maxRetries, + 'trxLabel' => $trxLabel, + 'errorInfo' => $e->errorInfo, + 'rawSql' => $rawSql, + 'connection' => $connectionName, + 'trace' => TraceFormatter::snapshot(), + ]); + } + + /** + * @throws RandomException + */ + protected static function nextBackoffInterval(int $baseDelay, int $attempt): int + { + $delay = max(1, (int) round($baseDelay * pow(2, max(0, $attempt - 1)))); + $jitter = max(0, (int) round($delay * 0.25)); + $min = max(1, $delay - $jitter); + $max = $delay + $jitter; + + return random_int($min, $max); + } + + protected static function logOutcome( + array $logEntries, + string $logFileName, + ?Throwable $throwable, + bool $exceptionCaught, + bool $shouldRetryError + ): void { + if (is_null($throwable) && ! $exceptionCaught) { + if (count($logEntries) > 0) { + DeadlockLogWriter::write($logEntries[count($logEntries) - 1], $logFileName, 'warning'); + } + + return; + } + + if (! is_null($throwable) && $shouldRetryError && count($logEntries) > 0) { + DeadlockLogWriter::write($logEntries[count($logEntries) - 1], $logFileName); + } + + // Non-retryable errors rethrow outside this helper; only log when retries are exhausted. + } + + protected static function pause(int $seconds): void + { + $overriddenSleep = 'MysqlDeadlocks\\RetryHelper\\sleep'; + + if (function_exists($overriddenSleep)) { + $overriddenSleep($seconds); + + return; + } + + sleep($seconds); + } +} diff --git a/src/Support/BindingStringifier.php b/src/Support/BindingStringifier.php new file mode 100644 index 0000000..bc268b5 --- /dev/null +++ b/src/Support/BindingStringifier.php @@ -0,0 +1,39 @@ +format('Y-m-d H:i:s.u'); + } + if (is_object($binding)) { + return '[object ' . get_class($binding) . ']'; + } + if (is_resource($binding)) { + return '[resource]'; + } + if (is_string($binding)) { + return mb_strlen($binding) > 500 + ? (mb_substr($binding, 0, 500) . '…[+trimmed]') + : $binding; + } + if (is_array($binding)) { + $json = @json_encode($binding, JSON_UNESCAPED_UNICODE); + + if ($json === false) { + return '[array]'; + } + + return mb_strlen($json) > 500 + ? (mb_substr($json, 0, 500) . '…[+trimmed]') + : $json; + } + + return $binding; + }, $bindings); + } +} diff --git a/src/Support/DeadlockLogWriter.php b/src/Support/DeadlockLogWriter.php new file mode 100644 index 0000000..58d1b84 --- /dev/null +++ b/src/Support/DeadlockLogWriter.php @@ -0,0 +1,38 @@ +toDateString() : date('Y-m-d'); + + $logFilePath = empty($logFileName) + ? storage_path('logs/' . $date . '/general.log') + : storage_path('logs/' . $date . "/{$logFileName}.log"); + + $logger = Log::build([ + 'driver' => 'single', + 'path' => $logFilePath, + ]); + + $context = is_array($payload) ? $payload : ['message' => (string) $payload]; + $attempts = $context['attempt'] ?? 0; + $max = $context['maxRetries'] ?? 0; + $label = $context['trxLabel'] ?? ''; + + $title = sprintf( + '[%s] [MYSQL DEADLOCK RETRY - %s] After (Attempts: %d/%d) - %s', + $label, + strtoupper($level === 'warning' ? 'SUCCESS' : 'FAILED'), + $attempts, + $max, + ucfirst($level) + ); + + $logger->{$level}($title, $context); + } +} diff --git a/src/Support/TraceFormatter.php b/src/Support/TraceFormatter.php new file mode 100644 index 0000000..ce75ade --- /dev/null +++ b/src/Support/TraceFormatter.php @@ -0,0 +1,26 @@ +map(static fn (array $frame) => [ + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + 'function' => $frame['function'] ?? null, + 'class' => $frame['class'] ?? null, + 'type' => $frame['type'] ?? null, + ])->all(); + } catch (Throwable) { + return []; + } + } +} diff --git a/tests/Unit/DBTransactionRetryHelperTest.php b/tests/Unit/DBTransactionRetryHelperTest.php index 34a27bd..04bd5be 100644 --- a/tests/Unit/DBTransactionRetryHelperTest.php +++ b/tests/Unit/DBTransactionRetryHelperTest.php @@ -10,7 +10,7 @@ function sleep(int $seconds): void namespace Tests; use Illuminate\Database\QueryException; -use MysqlDeadlocks\RetryHelper\DBTransactionRetryHelper; +use MysqlDeadlocks\RetryHelper\Services\DeadlockTransactionRetrier; beforeEach(function (): void { $this->database = new FakeDatabaseManager(); @@ -23,7 +23,7 @@ function sleep(int $seconds): void }); test('returns callback result without retries', function (): void { - $result = DBTransactionRetryHelper::transactionWithRetry(fn () => 'done'); + $result = DeadlockTransactionRetrier::runWithRetry(fn () => 'done'); expect($result)->toBe('done'); expect($this->database->transactionCalls)->toBe(1); @@ -34,7 +34,7 @@ function sleep(int $seconds): void test('retries on deadlock and logs warning', function (): void { $attempts = 0; - $result = DBTransactionRetryHelper::transactionWithRetry(function () use (&$attempts) { + $result = DeadlockTransactionRetrier::runWithRetry(function () use (&$attempts) { $attempts++; if ($attempts === 1) { @@ -61,7 +61,7 @@ function sleep(int $seconds): void test('throws after max retries and logs error', function (): void { try { - DBTransactionRetryHelper::transactionWithRetry(function (): void { + DeadlockTransactionRetrier::runWithRetry(function (): void { throw makeQueryException(1213); }, maxRetries: 3, retryDelay: 1, trxLabel: 'payments'); @@ -88,7 +88,7 @@ function sleep(int $seconds): void test('does not retry for non deadlock query exception', function (): void { try { - DBTransactionRetryHelper::transactionWithRetry(function (): void { + DeadlockTransactionRetrier::runWithRetry(function (): void { throw makeQueryException(999, 0); }, maxRetries: 3, retryDelay: 1); From fcca36cb3e28e0356b4d3300a0f94ee9999058e1 Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Fri, 17 Oct 2025 17:06:03 +0300 Subject: [PATCH 2/2] Configures retry logic via config file Adds a configuration file to manage default values for retry attempts, delay, and logging. This change allows users to customize the retry behavior via a published configuration file, offering flexibility and avoiding hardcoded values. It also introduces configurable log levels for success and failure cases. --- .php-cs-fixer.cache | 2 +- README.md | 24 +++- config/mysql-deadlock-retry.php | 45 ++++++++ .../DatabaseRetryServiceProvider.php | 15 ++- src/Services/DeadlockTransactionRetrier.php | 76 ++++++++---- src/Support/DeadlockLogWriter.php | 109 ++++++++++++++++-- tests/TestCase.php | 9 +- tests/Unit/DBTransactionRetryHelperTest.php | 18 +-- 8 files changed, 244 insertions(+), 54 deletions(-) create mode 100644 config/mysql-deadlock-retry.php diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 20a72e1..cf130ad 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.2.29","version":"3.88.2:v3.88.2#a8d15584bafb0f0d9d938827840060fd4a3ebc99","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","src\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","tests\/TestCase.php":"6df2b13208f4952f10b306fad99e1c51","tests\/bootstrap.php":"8af7490a2832c4cce20f0980636bad41","tests\/DBTransactionRetryHelperTest.php":"5e9993c586d9318449b2181ece54bc73","\/tmp\/PHP CS Fixertemp_folder\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder1\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20",".php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder2\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder10\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder4\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder5\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder11\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder9\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder815\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder8\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder3\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder7\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder6\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder1\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"40363cf626a0e00d5bacad08e35e194d","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","src\/Providers\/DatabaseRetryServiceProvider.php":"8872d0bf1c662e68b70c591e2c987359","src\/Services\/DeadlockTransactionRetrier.php":"b41008b1b4d2cfde2df03a4f164ef44d","src\/Support\/DeadlockLogWriter.php":"9e26ef11c8d2311ab2752a3192a13612","src\/Support\/BindingStringifier.php":"3aa21139dad20340d9518fa57e0845ca","src\/Support\/TraceFormatter.php":"13f19f8c9de611faa05847ae3890b73d"}} \ No newline at end of file +{"php":"8.2.29","version":"3.88.2:v3.88.2#a8d15584bafb0f0d9d938827840060fd4a3ebc99","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","src\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","tests\/TestCase.php":"78359ccc3cc8934fc6b788bb9f8455df","tests\/bootstrap.php":"8af7490a2832c4cce20f0980636bad41","tests\/DBTransactionRetryHelperTest.php":"5e9993c586d9318449b2181ece54bc73","\/tmp\/PHP CS Fixertemp_folder\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder1\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20",".php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder2\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder10\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder4\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder5\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder11\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder9\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder815\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder8\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder3\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder7\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder6\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder1\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"0a3d3a6bed6c3bd5af47a71e29a5be92","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","src\/Providers\/DatabaseRetryServiceProvider.php":"c4b1b48a744c843ed40bb818370ab922","src\/Services\/DeadlockTransactionRetrier.php":"62fc72973cf461c9029fdddaa4d721ce","src\/Support\/DeadlockLogWriter.php":"b298e47ae03b1255eb9d09d8c3758ef4","src\/Support\/BindingStringifier.php":"3aa21139dad20340d9518fa57e0845ca","src\/Support\/TraceFormatter.php":"13f19f8c9de611faa05847ae3890b73d"}} \ No newline at end of file diff --git a/README.md b/README.md index 41d2466..8e72f6f 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,29 @@ $order = Retry::runWithRetry( | Parameter | Default | Description | | ------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `maxRetries` | `3` | Total number of attempts (initial try + retries). | -| `retryDelay` | `2` | Base delay (seconds). Actual wait uses exponential backoff with ±25% jitter. | -| `logFileName` | `database/mysql-deadlocks` | Written to `storage/logs/{Y-m-d}/{logFileName}.log`. Can point to subdirectories. | +| `maxRetries` | Config (`default: 3`) | Total number of attempts (initial try + retries). | +| `retryDelay` | Config (`default: 2s`) | Base delay (seconds). Actual wait uses exponential backoff with ±25% jitter. | +| `logFileName` | Config (`default: database/mysql-deadlocks`) | Written to `storage/logs/{Y-m-d}/{logFileName}.log`. Can point to subdirectories. | | `trxLabel` | `''` | Optional label injected into log titles and stored in the service container as `tx.label` for downstream consumers. | Call the helper anywhere you would normally open a transaction—controllers, jobs, console commands, or domain services. +## Configuration + +Publish the configuration file to tweak defaults globally: + +```bash +php artisan vendor:publish --tag=mysql-deadlock-retry-config +``` + +Key options (`config/mysql-deadlock-retry.php`): + +- `max_retries`, `retry_delay`, and `log_file_name` set the package-wide defaults when you omit parameters. Each respects the matching environment variable (`MYSQL_DEADLOCK_MAX_RETRIES`, `MYSQL_DEADLOCK_RETRY_DELAY`, `MYSQL_DEADLOCK_LOG_FILE`). +- `logging.channel` points at any existing Laravel log channel so you can reuse stacks or third-party drivers. +- `logging.config` provides a full configuration array for `Log::build()` when you want a dedicated writer. +- `logging.via` accepts a container binding, class name, or callable that resolves a PSR-3 logger—ideal when you need to hand logs off to a completely custom pipeline. +- `logging.levels.success` / `logging.levels.failure` let you tune the severity emitted for successful retries and exhausted attempts (defaults: `warning` and `error`). + ## Retry Conditions Retries are attempted only when the caught exception is an `Illuminate\Database\QueryException` that matches one of: @@ -82,7 +98,7 @@ If no attempt succeeds and all retries are exhausted, the last `QueryException` ## Logging Behaviour -Logs are written using a dedicated single-file channel per day: +By default, logs are written using a dedicated single-file channel per day. Override `logging.channel`, `logging.config`, or `logging.via` to integrate with your own logging stack: - Success after retries → a warning entry titled `"[trxLabel] [MYSQL DEADLOCK RETRY - SUCCESS] After (Attempts: x/y) - Warning"`. - Failure after exhausting retries → an error entry titled `"[trxLabel] [MYSQL DEADLOCK RETRY - FAILED] After (Attempts: x/y) - Error"`. diff --git a/config/mysql-deadlock-retry.php b/config/mysql-deadlock-retry.php new file mode 100644 index 0000000..fbef2d7 --- /dev/null +++ b/config/mysql-deadlock-retry.php @@ -0,0 +1,45 @@ + (int) env('MYSQL_DEADLOCK_MAX_RETRIES', 3), + + 'retry_delay' => (int) env('MYSQL_DEADLOCK_RETRY_DELAY', 2), + + 'log_file_name' => env('MYSQL_DEADLOCK_LOG_FILE', 'database/mysql-deadlocks'), + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + | + | Control how retry attempts are logged. Provide a `channel` to reuse any + | logging channel defined in your application, supply a `config` array to + | build a dedicated logger on the fly. When none are defined, + | the package will continue to emit dated single-file logs per prior + | behaviour. + | + */ + + 'logging' => [ + 'channel' => env('MYSQL_DEADLOCK_LOG_CHANNEL'), + + 'config' => null, + + 'levels' => [ + 'success' => env('MYSQL_DEADLOCK_LOG_SUCCESS_LEVEL', 'warning'), + 'failure' => env('MYSQL_DEADLOCK_LOG_FAILURE_LEVEL', 'error'), + ], + ], +]; + diff --git a/src/Providers/DatabaseRetryServiceProvider.php b/src/Providers/DatabaseRetryServiceProvider.php index 31b6073..544febb 100644 --- a/src/Providers/DatabaseRetryServiceProvider.php +++ b/src/Providers/DatabaseRetryServiceProvider.php @@ -8,7 +8,10 @@ class DatabaseRetryServiceProvider extends ServiceProvider { public function register(): void { - // Register bindings or aliases related to retry helpers here if needed. + $this->mergeConfigFrom( + __DIR__ . '/../../config/mysql-deadlock-retry.php', + 'mysql-deadlock-retry' + ); } /** @@ -16,6 +19,14 @@ public function register(): void */ public function boot(): void { - // Hook for publishing configuration or adding macros in future versions. + if ($this->app->runningInConsole()) { + $configPath = function_exists('config_path') + ? config_path('mysql-deadlock-retry.php') + : $this->app->basePath('config/mysql-deadlock-retry.php'); + + $this->publishes([ + __DIR__ . '/../../config/mysql-deadlock-retry.php' => $configPath, + ], 'mysql-deadlock-retry-config'); + } } } diff --git a/src/Services/DeadlockTransactionRetrier.php b/src/Services/DeadlockTransactionRetrier.php index c7801d8..7d7a392 100644 --- a/src/Services/DeadlockTransactionRetrier.php +++ b/src/Services/DeadlockTransactionRetrier.php @@ -17,20 +17,29 @@ class DeadlockTransactionRetrier * Run the supplied callback inside a transaction with retry logic for MySQL deadlocks and serialization errors. * * @param Closure $callback The transaction logic to execute. - * @param int $maxRetries Number of times to retry on deadlock. - * @param int $retryDelay Delay between retries in seconds (base for backoff). - * @param string $logFileName The log file name. + * @param int|null $maxRetries Number of times to retry on deadlock. Falls back to configuration. + * @param int|null $retryDelay Delay between retries in seconds (base for backoff). Falls back to configuration. + * @param string|null $logFileName The log file name. Falls back to configuration. * @param string $trxLabel The transaction label. * @throws RandomException * @throws Throwable */ public static function runWithRetry( Closure $callback, - int $maxRetries = 3, - int $retryDelay = 2, - string $logFileName = 'database/mysql-deadlocks', + ?int $maxRetries = null, + ?int $retryDelay = null, + ?string $logFileName = null, string $trxLabel = '' ): mixed { + $config = function_exists('config') ? config('mysql-deadlock-retry', []) : []; + + $maxRetries ??= (int) ($config['max_retries'] ?? 3); + $retryDelay ??= (int) ($config['retry_delay'] ?? 2); + $logFileName ??= (string) ($config['log_file_name'] ?? 'database/mysql-deadlocks'); + + $maxRetries = max(1, $maxRetries); + $retryDelay = max(1, $retryDelay); + $trxLabel = $trxLabel ?? ''; $attempt = 0; @@ -81,19 +90,6 @@ public static function runWithRetry( throw new RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.'); } - /** - * @deprecated Use runWithRetry() instead. - */ - public static function transactionWithRetry( - Closure $callback, - int $maxRetries = 3, - int $retryDelay = 2, - string $logFileName = 'database/mysql-deadlocks', - string $trxLabel = '' - ): mixed { - return static::runWithRetry($callback, $maxRetries, $retryDelay, $logFileName, $trxLabel); - } - protected static function shouldRetry(QueryException $e): bool { return static::isDeadlock($e) || static::isSerializationFailure($e); @@ -180,16 +176,24 @@ protected static function logOutcome( bool $exceptionCaught, bool $shouldRetryError ): void { + $levels = static::configuredLogLevels(); + if (is_null($throwable) && ! $exceptionCaught) { if (count($logEntries) > 0) { - DeadlockLogWriter::write($logEntries[count($logEntries) - 1], $logFileName, 'warning'); + $entry = $logEntries[count($logEntries) - 1]; + $entry['retryStatus'] = 'success'; + + DeadlockLogWriter::write($entry, $logFileName, $levels['success']); } return; } if (! is_null($throwable) && $shouldRetryError && count($logEntries) > 0) { - DeadlockLogWriter::write($logEntries[count($logEntries) - 1], $logFileName); + $entry = $logEntries[count($logEntries) - 1]; + $entry['retryStatus'] = 'failure'; + + DeadlockLogWriter::write($entry, $logFileName, $levels['failure']); } // Non-retryable errors rethrow outside this helper; only log when retries are exhausted. @@ -207,4 +211,34 @@ protected static function pause(int $seconds): void sleep($seconds); } + + protected static function configuredLogLevels(): array + { + $defaults = [ + 'success' => 'warning', + 'failure' => 'error', + ]; + + if (! function_exists('config')) { + return $defaults; + } + + $levels = config('mysql-deadlock-retry.logging.levels', []); + + if (! is_array($levels)) { + return $defaults; + } + + return [ + 'success' => static::normaliseLogLevel($levels['success'] ?? null, $defaults['success']), + 'failure' => static::normaliseLogLevel($levels['failure'] ?? null, $defaults['failure']), + ]; + } + + protected static function normaliseLogLevel(?string $level, string $fallback): string + { + $candidate = is_string($level) ? strtolower(trim($level)) : null; + + return $candidate !== '' ? $candidate : $fallback; + } } diff --git a/src/Support/DeadlockLogWriter.php b/src/Support/DeadlockLogWriter.php index 58d1b84..863180e 100644 --- a/src/Support/DeadlockLogWriter.php +++ b/src/Support/DeadlockLogWriter.php @@ -3,36 +3,121 @@ namespace MysqlDeadlocks\RetryHelper\Support; use Illuminate\Support\Facades\Log; +use Psr\Log\LoggerInterface; +use Throwable; class DeadlockLogWriter { public static function write(array $payload, string $logFileName, string $level = 'error'): void { - $date = function_exists('now') ? now()->toDateString() : date('Y-m-d'); - - $logFilePath = empty($logFileName) - ? storage_path('logs/' . $date . '/general.log') - : storage_path('logs/' . $date . "/{$logFileName}.log"); + $logger = static::resolveLogger($logFileName); - $logger = Log::build([ - 'driver' => 'single', - 'path' => $logFilePath, - ]); + $levels = static::configuredLevels(); $context = is_array($payload) ? $payload : ['message' => (string) $payload]; $attempts = $context['attempt'] ?? 0; $max = $context['maxRetries'] ?? 0; $label = $context['trxLabel'] ?? ''; + $normalizedLevel = static::normalizeLevel($level, $levels['failure']); + $status = strtolower((string) ($context['retryStatus'] ?? ($normalizedLevel === $levels['success'] ? 'success' : 'failure'))); + $statusLabel = strtoupper($status === 'success' ? 'SUCCESS' : 'FAILED'); + $title = sprintf( '[%s] [MYSQL DEADLOCK RETRY - %s] After (Attempts: %d/%d) - %s', $label, - strtoupper($level === 'warning' ? 'SUCCESS' : 'FAILED'), + $statusLabel, $attempts, $max, - ucfirst($level) + ucfirst($normalizedLevel) ); - $logger->{$level}($title, $context); + $logger->log($normalizedLevel, $title, $context); + } + + protected static function resolveLogger(string $logFileName): LoggerInterface + { + $logging = []; + + if (function_exists('config')) { + $config = config('mysql-deadlock-retry.logging', []); + if (is_array($config)) { + $logging = $config; + } + } + + if (! empty($logging['channel']) && $logger = static::resolveChannel($logging['channel'])) { + return $logger; + } + + if (! empty($logging['config']) && is_array($logging['config'])) { + if ($logger = static::resolveBuilder($logging['config'])) { + return $logger; + } + } + + return static::defaultLogger($logFileName); + } + + protected static function resolveChannel(string $channel): ?LoggerInterface + { + try { + return Log::channel($channel); + } catch (Throwable) { + return null; + } + } + + protected static function resolveBuilder(array $config): ?LoggerInterface + { + try { + return Log::build($config); + } catch (Throwable) { + return null; + } + } + + protected static function defaultLogger(string $logFileName): LoggerInterface + { + $date = function_exists('now') ? now()->toDateString() : date('Y-m-d'); + + $logFilePath = empty($logFileName) + ? storage_path('logs/' . $date . '/general.log') + : storage_path('logs/' . $date . "/{$logFileName}.log"); + + return Log::build([ + 'driver' => 'single', + 'path' => $logFilePath, + ]); + } + + protected static function configuredLevels(): array + { + $defaults = [ + 'success' => 'warning', + 'failure' => 'error', + ]; + + if (! function_exists('config')) { + return $defaults; + } + + $levels = config('mysql-deadlock-retry.logging.levels', []); + + if (! is_array($levels)) { + return $defaults; + } + + return [ + 'success' => static::normalizeLevel($levels['success'] ?? null, $defaults['success']), + 'failure' => static::normalizeLevel($levels['failure'] ?? null, $defaults['failure']), + ]; + } + + protected static function normalizeLevel(?string $level, string $fallback): string + { + $candidate = is_string($level) ? strtolower(trim($level)) : null; + + return $candidate !== '' ? $candidate : $fallback; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 427f357..f5a8971 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,7 @@ namespace Tests; +use Illuminate\Config\Repository; use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use PHPUnit\Framework\TestCase as BaseTestCase; @@ -22,7 +23,13 @@ protected function setUp(): void Facade::setFacadeApplication($this->app); Facade::clearResolvedInstances(); - $this->app->instance('config', []); + $configRepository = new Repository(); + $configRepository->set( + 'mysql-deadlock-retry', + require dirname(__DIR__) . '/config/mysql-deadlock-retry.php' + ); + + $this->app->instance('config', $configRepository); $this->app->instance('path.storage', $this->app->storagePath()); } diff --git a/tests/Unit/DBTransactionRetryHelperTest.php b/tests/Unit/DBTransactionRetryHelperTest.php index 04bd5be..e761348 100644 --- a/tests/Unit/DBTransactionRetryHelperTest.php +++ b/tests/Unit/DBTransactionRetryHelperTest.php @@ -11,6 +11,7 @@ function sleep(int $seconds): void use Illuminate\Database\QueryException; use MysqlDeadlocks\RetryHelper\Services\DeadlockTransactionRetrier; +use Psr\Log\AbstractLogger; beforeEach(function (): void { $this->database = new FakeDatabaseManager(); @@ -178,26 +179,17 @@ public function build(array $config): FakeLogger } } -final class FakeLogger +final class FakeLogger extends AbstractLogger { public function __construct(private FakeLogManager $manager) { } - public function warning(string $message, array $context = []): void + public function log($level, $message, array $context = []): void { $this->manager->records[] = [ - 'level' => 'warning', - 'message' => $message, - 'context' => $context, - ]; - } - - public function error(string $message, array $context = []): void - { - $this->manager->records[] = [ - 'level' => 'error', - 'message' => $message, + 'level' => strtolower((string) $level), + 'message' => (string) $message, 'context' => $context, ]; }