From 4e1ee72a75bba627d295418c2b85793e0a2b1655 Mon Sep 17 00:00:00 2001 From: Znar Khalil Date: Sun, 19 Oct 2025 13:45:55 +0200 Subject: [PATCH] Enhance transaction retry logic with improved deadlock handling and logging context --- .php-cs-fixer.cache | 2 +- src/DBTransactionRetryHelper.php | 78 ++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index baeb063..62ed84a 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.4.13","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\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3619e5ff7703069c1f184c1fd247bbce","src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9","tests\/Unit\/DBTransactionRetryHelperTest.php":"38a42cae2dcaf6fa55519bec4b64e252","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/bootstrap.php":"8af7490a2832c4cce20f0980636bad41","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","tests\/TestCase.php":"6df2b13208f4952f10b306fad99e1c51"}} \ No newline at end of file diff --git a/src/DBTransactionRetryHelper.php b/src/DBTransactionRetryHelper.php index 5ec2ab5..bcaa7d4 100644 --- a/src/DBTransactionRetryHelper.php +++ b/src/DBTransactionRetryHelper.php @@ -26,8 +26,14 @@ public static function transactionWithRetry(Closure $callback, int $maxRetries = if (is_null($trxLabel)) { $trxLabel = ''; } - $attempt = 0; - $log = []; + + if ($trxLabel !== '') { + app()->instance('tx.label', $trxLabel); + } + + $attempt = 0; + $lastDeadlockException = null; + $lastDeadlockAttempt = 0; while ($attempt < $maxRetries) { // reset per-attempt flags to avoid stale values @@ -37,7 +43,6 @@ public static function transactionWithRetry(Closure $callback, int $maxRetries = try { // Execute the transaction - $trxLabel === '' || app()->instance('tx.label', $trxLabel); $result = DB::transaction($callback); return $result; @@ -50,7 +55,8 @@ public static function transactionWithRetry(Closure $callback, int $maxRetries = if ($isDeadlock) { $attempt++; - $log[] = static::buildLogContext($e, $attempt, $maxRetries, $trxLabel); + $lastDeadlockException = $e; + $lastDeadlockAttempt = $attempt; if ($attempt >= $maxRetries) { // exhausted retries — throw after logging below in finally @@ -63,20 +69,31 @@ public static function transactionWithRetry(Closure $callback, int $maxRetries = } } else { // Non-deadlock: DO NOT log, just rethrow - $throwable = $e; + $lastDeadlockException = null; + $lastDeadlockAttempt = 0; + $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) { + if ($lastDeadlockException !== null) { // optional: downgrade to warning for eventual success after retries - generateLog($log[count($log) - 1], $logFileName, 'warning'); + generateLog( + static::buildLogContext($lastDeadlockException, $lastDeadlockAttempt, $maxRetries, $trxLabel), + $logFileName, + 'warning' + ); + $lastDeadlockException = null; + $lastDeadlockAttempt = 0; } } 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); + if ($isDeadlock && $lastDeadlockException !== null) { + generateLog( + static::buildLogContext($lastDeadlockException, $lastDeadlockAttempt, $maxRetries, $trxLabel), + $logFileName + ); } // For NON-deadlock, nothing is logged — just throw. @@ -107,15 +124,18 @@ protected static function buildLogContext(QueryException $e, int $attempt, int $ $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); + if ($rawSql === null && $sql !== null && $bindings !== []) { + static $grammarCache = []; + + $key = $connectionName ?? '__default__'; + $grammar = $grammarCache[$key] ??= DB::connection($connectionName)->getQueryGrammar(); + + $rawSql = $grammar->substituteBindingsIntoRawSql($sql, $bindings); } - $requestData = [ + $context = [ 'url' => null, 'method' => null, 'token' => null, @@ -124,28 +144,28 @@ protected static function buildLogContext(QueryException $e, int $attempt, int $ 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; + $req = request(); + $context['url'] = method_exists($req, 'getUri') ? $req->getUri() : null; + $context['method'] = method_exists($req, 'getMethod') ? $req->getMethod() : null; if (method_exists($req, 'header')) { - $auth = $req->header('authorization'); - $requestData['authHeaderLen'] = $auth ? strlen($auth) : null; + $auth = $req->header('authorization'); + $context['authHeaderLen'] = $auth ? strlen($auth) : null; } - $requestData['userId'] = method_exists($req, 'user') && $req->user() ? ($req->user()->id ?? null) : null; + $context['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(), - ]); + $context['attempt'] = $attempt; + $context['maxRetries'] = $maxRetries; + $context['trxLabel'] = $trxLabel; + $context['errorInfo'] = $e->errorInfo; + $context['rawSql'] = $rawSql; + $context['connection'] = $connectionName; + $context['trace'] = getDebugBacktraceArray(); + + return $context; } /**