From 302ffed7f1965eede2eff5db2b928bd598953b4a Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Tue, 21 Oct 2025 20:12:57 +0300 Subject: [PATCH 1/4] Generalizes retry helper for database transactions Refactors the package to support retries for a broader range of database transaction errors, not just MySQL deadlocks. - Updates configuration to allow defining retryable SQL states, driver error codes, and exception classes. - Renames classes and configuration files to reflect the broader scope. - Adapts logging to include exception details, SQL state, and driver code. --- .php-cs-fixer.cache | 2 +- README.md | 77 ++--- composer.json | 8 +- composer.lock | 2 +- config/database-transaction-retry.php | 67 ++++ config/mysql-deadlock-retry.php | 45 --- .../DatabaseRetryServiceProvider.php | 32 -- ...atabaseTransactionRetryServiceProvider.php | 32 ++ src/Services/DeadlockTransactionRetrier.php | 244 -------------- src/Services/TransactionRetrier.php | 299 ++++++++++++++++++ src/Support/BindingStringifier.php | 6 +- src/Support/TraceFormatter.php | 2 +- ...iter.php => TransactionRetryLogWriter.php} | 27 +- tests/TestCase.php | 6 +- tests/Unit/DBTransactionRetryHelperTest.php | 84 ++++- tests/bootstrap.php | 2 +- 16 files changed, 548 insertions(+), 387 deletions(-) create mode 100644 config/database-transaction-retry.php delete mode 100644 config/mysql-deadlock-retry.php delete mode 100644 src/Providers/DatabaseRetryServiceProvider.php create mode 100644 src/Providers/DatabaseTransactionRetryServiceProvider.php delete mode 100644 src/Services/DeadlockTransactionRetrier.php create mode 100644 src/Services/TransactionRetrier.php rename src/Support/{DeadlockLogWriter.php => TransactionRetryLogWriter.php} (75%) diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index cf130ad..2d6ce58 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":"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 +{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"01b810fda87e28b849389c952b6141e9","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"149e31651f0e22a15a7df90c9edb5baa","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"7202d36b32e4d5c70cc19d83392cdda1","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}} \ No newline at end of file diff --git a/README.md b/README.md index 8e72f6f..82befbe 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@

- MySQL Deadlock Retry Helper + Database Transaction Retry Helper

- - Tests + + Tests - - Packagist Version + + Packagist Version MIT License @@ -18,29 +18,29 @@

-Resilient database transactions for Laravel applications that need to gracefully handle MySQL deadlocks and serialization failures. This helper wraps `DB::transaction()` with targeted retries, structured logging, and exponential backoff so you can keep your business logic simple while surviving transient contention. +Resilient database transactions for Laravel applications that need to gracefully handle deadlocks, serialization failures, and any other transient database errors you configure. This helper wraps `DB::transaction()` with targeted retries, structured logging, and exponential backoff so you can keep your business logic simple while surviving temporary contention. ## Highlights -- Retries only known transient failure scenarios (MySQL driver error `1213` and SQLSTATE `40001`), leaving all other exceptions untouched. +- Retries known transient failures out of the box (SQLSTATE `40001`, MySQL driver error `1213`), and lets you add extra SQLSTATE codes, driver error codes, or exception classes through configuration. - Exponential backoff with jitter between attempts to reduce stampedes under load. - Structured logs with request metadata, SQL, bindings, connection information, and stack traces written to dated files under `storage/logs/{Y-m-d}`. -- Safe in HTTP, CLI, and queue contexts: request data is collected when available and ignored when not. +- 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. - Laravel package auto-discovery; no manual service provider registration required. ## Installation ```bash -composer require ahed92wakim/laravel-mysql-deadlock-retry +composer require ahed92wakim/laravel-db-transaction-retry ``` -The package ships with the `DatabaseRetryServiceProvider`, which Laravel auto-discovers. No additional setup is needed. +The package ships with the `DatabaseTransactionRetryServiceProvider`, which Laravel auto-discovers. No additional setup is needed. ## Usage ```php -use MysqlDeadlocks\RetryHelper\Services\DeadlockTransactionRetrier as Retry; +use DatabaseTransactions\RetryHelper\Services\TransactionRetrier as Retry; $order = Retry::runWithRetry( function () use ($payload) { @@ -51,21 +51,21 @@ $order = Retry::runWithRetry( }, maxRetries: 4, retryDelay: 1, - logFileName: 'mysql-deadlocks/orders', + logFileName: 'database/transaction-retries/orders', trxLabel: 'order-create' ); ``` -`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. +`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. ### Parameters -| Parameter | Default | Description | -| ------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `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. | +| Parameter | Default | Description | +| ------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `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/transaction-retries`) | 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. @@ -74,39 +74,40 @@ Call the helper anywhere you would normally open a transaction—controllers, jo Publish the configuration file to tweak defaults globally: ```bash -php artisan vendor:publish --tag=mysql-deadlock-retry-config +php artisan vendor:publish --tag=database-transaction-retry-config ``` -Key options (`config/mysql-deadlock-retry.php`): +Key options (`config/database-transaction-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`). +- `max_retries`, `retry_delay`, and `log_file_name` set the package-wide defaults when you omit parameters. Each respects environment variables (`DB_TRANSACTION_RETRY_MAX_RETRIES`, `DB_TRANSACTION_RETRY_DELAY`, `DB_TRANSACTION_RETRY_LOG_FILE`). - `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`). +- `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`). +- `retryable_exceptions.classes` lets you specify fully-qualified exception class names that should always be retried. ## Retry Conditions -Retries are attempted only when the caught exception is an `Illuminate\Database\QueryException` that matches one of: +Retries are attempted when the caught exception matches one of the configured conditions: -- SQLSTATE `40001` (serialization failure). -- MySQL driver error `1213` (deadlock), whether reported via SQLSTATE or the driver error code. +- `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`. +- Any exception instance whose class appears in `retryable_exceptions.classes`. -Everything else (e.g., constraint violations, syntax errors, driver error `1205`, application exceptions) is surfaced immediately without logging or sleeping. - -If no attempt succeeds and all retries are exhausted, the last `QueryException` is re-thrown. In the rare case nothing is thrown but the loop exits, a `RuntimeException` is raised to signal exhaustion. +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. ## Logging Behaviour -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: +By default, logs are written using a dedicated single-file channel per day. Override `logging.channel` or `logging.config` to integrate with your own logging stack: -- 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"`. +- Success after retries → a warning entry titled `"[trxLabel] [DATABASE TRANSACTION RETRY - SUCCESS] ExceptionClass (Codes) After (Attempts: x/y) - Warning"`. +- Failure after exhausting retries → an error entry titled `"[trxLabel] [DATABASE TRANSACTION RETRY - FAILED] ExceptionClass (Codes) After (Attempts: x/y) - Error"`. Each log entry includes: - Attempt count, maximum retries, and transaction label. -- Connection name, SQL, resolved raw SQL (when bindings are available), and PDO error info. +- Exception class, SQLSTATE, driver error code, connection name, SQL, resolved raw SQL, and PDO error info when available. - A compacted stack trace and sanitized bindings. - Request URL, method, authorization header length, and authenticated user ID when the request helper is bound. @@ -116,11 +117,11 @@ Set `logFileName` to segment logs by feature or workload (e.g., `logFileName: 'd 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. +- `DatabaseTransactions\RetryHelper\Support\TransactionRetryLogWriter` writes structured entries using the same format as the retrier. +- `DatabaseTransactions\RetryHelper\Support\TraceFormatter` converts debug backtraces into log-friendly arrays. +- `DatabaseTransactions\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. +For testing scenarios, the retrier looks for a namespaced `DatabaseTransactions\RetryHelper\sleep()` function before falling back to PHP's global `sleep()`, making it easy to assert backoff intervals without introducing delays. ## Testing the Package @@ -130,7 +131,7 @@ Run the test suite with: composer test ``` -Tests cover the retry flow, logging behaviour, exponential backoff jitter, and non-deadlock scenarios using fakes for the database and logger managers. +Tests cover the retry flow, logging behaviour, exponential backoff jitter, and non-retryable scenarios using fakes for the database and logger managers. ## Requirements diff --git a/composer.json b/composer.json index 48f7038..5256d78 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "ahed92wakim/laravel-mysql-deadlock-retry", - "description": "Gracefully retry Laravel transactions on MySQL deadlocks and serialization failures with configurable backoff, structured logging, and reusable helpers.", + "name": "ahed92wakim/laravel-db-transaction-retry", + "description": "Gracefully retry Laravel transactions on database deadlocks and other retryable errors with configurable backoff, structured logging, and reusable helpers.", "type": "library", "minimum-stability": "stable", "require": { @@ -12,7 +12,7 @@ ], "autoload": { "psr-4": { - "MysqlDeadlocks\\RetryHelper\\": "src/" + "DatabaseTransactions\\RetryHelper\\": "src/" } }, "autoload-dev": { @@ -23,7 +23,7 @@ "extra": { "laravel": { "providers": [ - "MysqlDeadlocks\\RetryHelper\\Providers\\DatabaseRetryServiceProvider" + "DatabaseTransactions\\RetryHelper\\Providers\\DatabaseTransactionRetryServiceProvider" ] } }, diff --git a/composer.lock b/composer.lock index 6b429c6..c70b80f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b6dd3735e10c02ef8c3fcd8383840434", + "content-hash": "44f287b86eee043f0d40c2975a09c133", "packages": [ { "name": "brick/math", diff --git a/config/database-transaction-retry.php b/config/database-transaction-retry.php new file mode 100644 index 0000000..05ee244 --- /dev/null +++ b/config/database-transaction-retry.php @@ -0,0 +1,67 @@ + (int) env('DB_TRANSACTION_RETRY_MAX_RETRIES', 3), + + 'retry_delay' => (int) env('DB_TRANSACTION_RETRY_DELAY', 2), + + 'log_file_name' => env('DB_TRANSACTION_RETRY_LOG_FILE', 'database/transaction-retries'), + + /* + |-------------------------------------------------------------------------- + | 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('DB_TRANSACTION_RETRY_LOG_CHANNEL'), + + 'config' => null, + + 'levels' => [ + 'success' => env('DB_TRANSACTION_RETRY_LOG_SUCCESS_LEVEL', 'warning'), + 'failure' => env('DB_TRANSACTION_RETRY_LOG_FAILURE_LEVEL', 'error'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | 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. + | + */ + + 'retryable_exceptions' => [ + 'sql_states' => [ + '40001', // Serialization failure + ], + + 'driver_error_codes' => [ + 1213, // MySQL deadlock + ], + + 'classes' => [], + ], +]; diff --git a/config/mysql-deadlock-retry.php b/config/mysql-deadlock-retry.php deleted file mode 100644 index fbef2d7..0000000 --- a/config/mysql-deadlock-retry.php +++ /dev/null @@ -1,45 +0,0 @@ - (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 deleted file mode 100644 index 544febb..0000000 --- a/src/Providers/DatabaseRetryServiceProvider.php +++ /dev/null @@ -1,32 +0,0 @@ -mergeConfigFrom( - __DIR__ . '/../../config/mysql-deadlock-retry.php', - 'mysql-deadlock-retry' - ); - } - - /** - * Bootstrap any package services. - */ - public function boot(): void - { - 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/Providers/DatabaseTransactionRetryServiceProvider.php b/src/Providers/DatabaseTransactionRetryServiceProvider.php new file mode 100644 index 0000000..e4791b5 --- /dev/null +++ b/src/Providers/DatabaseTransactionRetryServiceProvider.php @@ -0,0 +1,32 @@ +mergeConfigFrom( + __DIR__ . '/../../config/database-transaction-retry.php', + 'database-transaction-retry' + ); + } + + /** + * Bootstrap any package services. + */ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $configPath = function_exists('config_path') + ? config_path('database-transaction-retry.php') + : $this->app->basePath('config/database-transaction-retry.php'); + + $this->publishes([ + __DIR__ . '/../../config/database-transaction-retry.php' => $configPath, + ], 'database-transaction-retry-config'); + } + } +} diff --git a/src/Services/DeadlockTransactionRetrier.php b/src/Services/DeadlockTransactionRetrier.php deleted file mode 100644 index 7d7a392..0000000 --- a/src/Services/DeadlockTransactionRetrier.php +++ /dev/null @@ -1,244 +0,0 @@ -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.'); - } - - 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 { - $levels = static::configuredLogLevels(); - - if (is_null($throwable) && ! $exceptionCaught) { - if (count($logEntries) > 0) { - $entry = $logEntries[count($logEntries) - 1]; - $entry['retryStatus'] = 'success'; - - DeadlockLogWriter::write($entry, $logFileName, $levels['success']); - } - - return; - } - - if (! is_null($throwable) && $shouldRetryError && count($logEntries) > 0) { - $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. - } - - protected static function pause(int $seconds): void - { - $overriddenSleep = 'MysqlDeadlocks\\RetryHelper\\sleep'; - - if (function_exists($overriddenSleep)) { - $overriddenSleep($seconds); - - return; - } - - 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/Services/TransactionRetrier.php b/src/Services/TransactionRetrier.php new file mode 100644 index 0000000..28d1eab --- /dev/null +++ b/src/Services/TransactionRetrier.php @@ -0,0 +1,299 @@ +instance('tx.label', $trxLabel); + + return DB::transaction($callback); + } catch (Throwable $exception) { + $exceptionCaught = true; + $shouldRetryError = static::shouldRetry($exception); + + if ($shouldRetryError) { + $attempt++; + $logEntries[] = static::makeRetryContext($exception, $attempt, $maxRetries, $trxLabel); + + if ($attempt >= $maxRetries) { + $throwable = $exception; + } else { + static::pause(static::nextBackoffInterval($retryDelay, $attempt)); + continue; + } + } else { + $throwable = $exception; + } + } finally { + static::logOutcome( + $logEntries, + $logFileName, + $throwable, + $exceptionCaught, + $shouldRetryError + ); + + if (! is_null($throwable)) { + throw $throwable; + } + } + } + + throw new RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.'); + } + + protected static function shouldRetry(Throwable $throwable): 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 static::isRetryableQueryException($throwable, $config); + } + + return false; + } + + 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; + } + + if (! is_null($driverErr) && in_array($driverErr, $driverCodes, true)) { + return true; + } + + return false; + } + + protected static function makeRetryContext(Throwable $throwable, int $attempt, int $maxRetries, string $trxLabel): array + { + $context = [ + 'attempt' => $attempt, + 'maxRetries' => $maxRetries, + 'trxLabel' => $trxLabel, + 'exceptionClass' => get_class($throwable), + ]; + + if ($throwable instanceof QueryException) { + $sql = method_exists($throwable, 'getSql') ? $throwable->getSql() : null; + $bindings = method_exists($throwable, 'getBindings') ? $throwable->getBindings() : []; + + $connectionName = $throwable->getConnectionName(); + $context['connection'] = $connectionName; + + $conn = DB::connection($connectionName); + + $rawSql = method_exists($throwable, 'getRawSql') ? $throwable->getRawSql() : null; + if (is_null($rawSql) && ! is_null($sql) && ! empty($bindings)) { + $rawSql = $conn->getQueryGrammar()->substituteBindingsIntoRawSql($sql, $bindings); + } + + $context['rawSql'] = $rawSql; + $context['errorInfo'] = $throwable->errorInfo; + $context['sqlState'] = isset($throwable->errorInfo[0]) + ? (string) $throwable->errorInfo[0] + : (method_exists($throwable, 'getCode') ? (string) $throwable->getCode() : null); + $context['driverCode'] = isset($throwable->errorInfo[1]) ? (int) $throwable->errorInfo[1] : null; + } + + $context['trace'] = TraceFormatter::snapshot(); + + try { + $context += static::requestSnapshot(); + } catch (Throwable) { + // ignore + } + + return $context; + } + + protected static function requestSnapshot(): array + { + $data = [ + 'url' => null, + 'method' => null, + 'token' => null, + 'userId' => null, + ]; + + if (! function_exists('request') || ! app()->bound('request')) { + return $data; + } + + $request = request(); + + $data['url'] = method_exists($request, 'getUri') ? $request->getUri() : null; + $data['method'] = method_exists($request, 'getMethod') ? $request->getMethod() : null; + + if (method_exists($request, 'header')) { + $auth = $request->header('authorization'); + $data['authHeaderLen'] = $auth ? strlen($auth) : null; + } + + $data['userId'] = method_exists($request, 'user') && $request->user() + ? ($request->user()->id ?? null) + : null; + + return $data; + } + + /** + * @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 { + $levels = static::configuredLogLevels(); + + if (is_null($throwable) && ! $exceptionCaught) { + if (count($logEntries) > 0) { + $entry = $logEntries[count($logEntries) - 1]; + $entry['retryStatus'] = 'success'; + + TransactionRetryLogWriter::write($entry, $logFileName, $levels['success']); + } + + return; + } + + if (! is_null($throwable) && $shouldRetryError && count($logEntries) > 0) { + $entry = $logEntries[count($logEntries) - 1]; + $entry['retryStatus'] = 'failure'; + + TransactionRetryLogWriter::write($entry, $logFileName, $levels['failure']); + } + + // Non-retryable errors rethrow outside this helper; only log when retries are exhausted. + } + + protected static function pause(int $seconds): void + { + $overriddenSleep = 'DatabaseTransactions\\RetryHelper\\sleep'; + + if (function_exists($overriddenSleep)) { + $overriddenSleep($seconds); + + return; + } + + sleep($seconds); + } + + protected static function configuredLogLevels(): array + { + $defaults = [ + 'success' => 'warning', + 'failure' => 'error', + ]; + + if (! function_exists('config')) { + return $defaults; + } + + $levels = config('database-transaction-retry.logging.levels', []); + + if (! is_array($levels)) { + return $defaults; + } + + return [ + 'success' => static::normalizeLogLevel($levels['success'] ?? null, $defaults['success']), + 'failure' => static::normalizeLogLevel($levels['failure'] ?? null, $defaults['failure']), + ]; + } + + protected static function normalizeLogLevel(?string $level, string $fallback): string + { + $candidate = is_string($level) ? strtolower(trim($level)) : null; + + return $candidate !== '' ? $candidate : $fallback; + } +} diff --git a/src/Support/BindingStringifier.php b/src/Support/BindingStringifier.php index bc268b5..45274b2 100644 --- a/src/Support/BindingStringifier.php +++ b/src/Support/BindingStringifier.php @@ -1,6 +1,6 @@ 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); diff --git a/src/Support/TraceFormatter.php b/src/Support/TraceFormatter.php index ce75ade..a308daf 100644 --- a/src/Support/TraceFormatter.php +++ b/src/Support/TraceFormatter.php @@ -1,6 +1,6 @@ (string) $payload]; - $attempts = $context['attempt'] ?? 0; - $max = $context['maxRetries'] ?? 0; - $label = $context['trxLabel'] ?? ''; + $attempts = (int) ($context['attempt'] ?? 0); + $max = (int) ($context['maxRetries'] ?? 0); + $label = (string) ($context['trxLabel'] ?? ''); $normalizedLevel = static::normalizeLevel($level, $levels['failure']); $status = strtolower((string) ($context['retryStatus'] ?? ($normalizedLevel === $levels['success'] ? 'success' : 'failure'))); $statusLabel = strtoupper($status === 'success' ? 'SUCCESS' : 'FAILED'); + $exceptionClass = (string) ($context['exceptionClass'] ?? 'UnknownException'); + $sqlState = (string) ($context['sqlState'] ?? ''); + $driverCode = $context['driverCode'] ?? null; + + $codeParts = []; + $sqlState !== '' && $codeParts[] = 'SQLSTATE ' . $sqlState; + ! is_null($driverCode) && $codeParts[] = 'Driver ' . $driverCode; + + $exceptionSummary = trim($exceptionClass . (count($codeParts) > 0 ? ' (' . implode(', ', $codeParts) . ')' : '')); + $title = sprintf( - '[%s] [MYSQL DEADLOCK RETRY - %s] After (Attempts: %d/%d) - %s', + '[%s] [DATABASE TRANSACTION RETRY - %s] %s After (Attempts: %d/%d) - %s', $label, $statusLabel, + $exceptionSummary, $attempts, $max, ucfirst($normalizedLevel) @@ -40,7 +51,7 @@ protected static function resolveLogger(string $logFileName): LoggerInterface $logging = []; if (function_exists('config')) { - $config = config('mysql-deadlock-retry.logging', []); + $config = config('database-transaction-retry.logging', []); if (is_array($config)) { $logging = $config; } @@ -102,7 +113,7 @@ protected static function configuredLevels(): array return $defaults; } - $levels = config('mysql-deadlock-retry.logging.levels', []); + $levels = config('database-transaction-retry.logging.levels', []); if (! is_array($levels)) { return $defaults; diff --git a/tests/TestCase.php b/tests/TestCase.php index f5a8971..ebac46d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,8 +25,8 @@ protected function setUp(): void $configRepository = new Repository(); $configRepository->set( - 'mysql-deadlock-retry', - require dirname(__DIR__) . '/config/mysql-deadlock-retry.php' + 'database-transaction-retry', + require dirname(__DIR__) . '/config/database-transaction-retry.php' ); $this->app->instance('config', $configRepository); @@ -47,7 +47,7 @@ final class TestApplication extends Container { public function storagePath($path = ''): string { - $base = sys_get_temp_dir() . '/laravel-mysql-deadlock-retry/storage'; + $base = sys_get_temp_dir() . '/laravel-db-transaction-retry/storage'; return $path === '' ? $base : $base . '/' . ltrim((string) $path, '/'); } diff --git a/tests/Unit/DBTransactionRetryHelperTest.php b/tests/Unit/DBTransactionRetryHelperTest.php index e761348..1c2a9e0 100644 --- a/tests/Unit/DBTransactionRetryHelperTest.php +++ b/tests/Unit/DBTransactionRetryHelperTest.php @@ -1,6 +1,6 @@ 'done'); + $result = TransactionRetrier::runWithRetry(fn () => 'done'); expect($result)->toBe('done'); expect($this->database->transactionCalls)->toBe(1); @@ -35,7 +36,7 @@ function sleep(int $seconds): void test('retries on deadlock and logs warning', function (): void { $attempts = 0; - $result = DeadlockTransactionRetrier::runWithRetry(function () use (&$attempts) { + $result = TransactionRetrier::runWithRetry(function () use (&$attempts) { $attempts++; if ($attempts === 1) { @@ -54,15 +55,18 @@ function sleep(int $seconds): void $record = $this->logManager->records[0]; expect($record['level'])->toBe('warning'); - expect($record['message'])->toBe('[orders] [MYSQL DEADLOCK RETRY - SUCCESS] After (Attempts: 1/3) - Warning'); + expect($record['message'])->toBe('[orders] [DATABASE TRANSACTION RETRY - SUCCESS] Illuminate\Database\QueryException (SQLSTATE 40001, Driver 1213) After (Attempts: 1/3) - Warning'); expect($record['context']['attempt'])->toBe(1); expect($record['context']['maxRetries'])->toBe(3); expect($record['context']['trxLabel'])->toBe('orders'); + expect($record['context']['exceptionClass'])->toBe(QueryException::class); + expect($record['context']['sqlState'])->toBe('40001'); + expect($record['context']['driverCode'])->toBe(1213); }); test('throws after max retries and logs error', function (): void { try { - DeadlockTransactionRetrier::runWithRetry(function (): void { + TransactionRetrier::runWithRetry(function (): void { throw makeQueryException(1213); }, maxRetries: 3, retryDelay: 1, trxLabel: 'payments'); @@ -81,15 +85,18 @@ function sleep(int $seconds): void $record = $this->logManager->records[0]; expect($record['level'])->toBe('error'); - expect($record['message'])->toBe('[payments] [MYSQL DEADLOCK RETRY - FAILED] After (Attempts: 3/3) - Error'); + expect($record['message'])->toBe('[payments] [DATABASE TRANSACTION RETRY - FAILED] Illuminate\Database\QueryException (SQLSTATE 40001, Driver 1213) After (Attempts: 3/3) - Error'); expect($record['context']['attempt'])->toBe(3); expect($record['context']['maxRetries'])->toBe(3); expect($record['context']['trxLabel'])->toBe('payments'); + expect($record['context']['exceptionClass'])->toBe(QueryException::class); + expect($record['context']['sqlState'])->toBe('40001'); + expect($record['context']['driverCode'])->toBe(1213); }); test('does not retry for non deadlock query exception', function (): void { try { - DeadlockTransactionRetrier::runWithRetry(function (): void { + TransactionRetrier::runWithRetry(function (): void { throw makeQueryException(999, 0); }, maxRetries: 3, retryDelay: 1); @@ -103,6 +110,63 @@ function sleep(int $seconds): void expect(SleepSpy::$delays)->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] + ); + + $attempts = 0; + + $result = TransactionRetrier::runWithRetry(function () use (&$attempts) { + $attempts++; + + if ($attempts === 1) { + throw new CustomRetryException('try again'); + } + + return 'ok'; + }, maxRetries: 3, retryDelay: 1, trxLabel: 'custom'); + + expect($result)->toBe('ok'); + expect($this->database->transactionCalls)->toBe(2); + + $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(); +}); + function makeQueryException(int $driverCode, int $sqlState = 40001): QueryException { $sqlStateString = str_pad((string) $sqlState, 5, '0', STR_PAD_LEFT); @@ -117,6 +181,10 @@ function makeQueryException(int $driverCode, int $sqlState = 40001): QueryExcept ); } +final class CustomRetryException extends \RuntimeException +{ +} + final class FakeDatabaseManager { public int $transactionCalls = 0; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index bcd6ff2..e63cc8d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,7 +7,7 @@ if (! function_exists('storage_path')) { function storage_path(string $path = ''): string { - $base = sys_get_temp_dir() . '/laravel-mysql-deadlock-retry'; + $base = sys_get_temp_dir() . '/laravel-db-transaction-retry'; return $path === '' ? $base : $base . '/' . ltrim($path, '/'); } From b573b040be5f02e3b13d4831d3ee6557ff864646 Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Tue, 21 Oct 2025 21:08:50 +0300 Subject: [PATCH 2/4] Adds lock wait timeout retry and configuration Adds the ability to retry transactions that fail due to lock wait timeouts. Introduces a configuration option to set the session-level lock wait timeout before each transaction attempt. This helps to prevent indefinite waiting and ensures a predictable timeout, even after reconnections or pool reuse. Updates the documentation to reflect the new functionality and configuration options. --- .gitignore | 2 + .php-cs-fixer.cache | 2 +- README.md | 16 +-- config/database-transaction-retry.php | 18 +++- src/Services/TransactionRetrier.php | 44 ++++++++ tests/Unit/DBTransactionRetryHelperTest.php | 105 +++++++++++++++++++- 6 files changed, 175 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index b960e00..3932318 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ vendor/ .idea/ .vscode/ .DS_Store + +.php-cs-fixer.cache diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 2d6ce58..3b8f5a5 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\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"01b810fda87e28b849389c952b6141e9","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"149e31651f0e22a15a7df90c9edb5baa","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"7202d36b32e4d5c70cc19d83392cdda1","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}} \ 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\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"7d99d773c44861e3f12524a5119f5240","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"149e31651f0e22a15a7df90c9edb5baa","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"07bb6b8e6c8b3ce61a7e67f128f12f4a","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}} \ No newline at end of file diff --git a/README.md b/README.md index 82befbe..bd7f709 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Resilient database transactions for Laravel applications that need to gracefully ## Highlights -- Retries known transient failures out of the box (SQLSTATE `40001`, MySQL driver error `1213`), and lets you add extra SQLSTATE codes, driver error codes, or exception classes through configuration. +- Retries known transient failures out of the box (SQLSTATE `40001`, MySQL driver errors `1213` and `1205`), and lets you add extra SQLSTATE codes, driver error codes, or exception classes through configuration. - Exponential backoff with jitter between attempts to reduce stampedes under load. - 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. @@ -77,14 +77,14 @@ Publish the configuration file to tweak defaults globally: php artisan vendor:publish --tag=database-transaction-retry-config ``` -Key options (`config/database-transaction-retry.php`): +- Key options (`config/database-transaction-retry.php`): - `max_retries`, `retry_delay`, and `log_file_name` set the package-wide defaults when you omit parameters. Each respects environment variables (`DB_TRANSACTION_RETRY_MAX_RETRIES`, `DB_TRANSACTION_RETRY_DELAY`, `DB_TRANSACTION_RETRY_LOG_FILE`). +- `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.config` provides a full configuration array for `Log::build()` when you want a dedicated writer. - `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`). +- `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 Conditions @@ -92,14 +92,18 @@ Key options (`config/database-transaction-retry.php`): 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`. +- `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`. 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. + ## Logging Behaviour -By default, logs are written using a dedicated single-file channel per day. Override `logging.channel` or `logging.config` to integrate with your own logging stack: +By default, logs are written using a dedicated single-file channel per day. Override `logging.channel` to integrate with your own logging stack: - Success after retries → a warning entry titled `"[trxLabel] [DATABASE TRANSACTION RETRY - SUCCESS] ExceptionClass (Codes) After (Attempts: x/y) - Warning"`. - Failure after exhausting retries → an error entry titled `"[trxLabel] [DATABASE TRANSACTION RETRY - FAILED] ExceptionClass (Codes) After (Attempts: x/y) - Error"`. diff --git a/config/database-transaction-retry.php b/config/database-transaction-retry.php index 05ee244..d21d942 100644 --- a/config/database-transaction-retry.php +++ b/config/database-transaction-retry.php @@ -16,6 +16,21 @@ 'retry_delay' => (int) env('DB_TRANSACTION_RETRY_DELAY', 2), + /* + |-------------------------------------------------------------------------- + | Lock Wait Timeout + |-------------------------------------------------------------------------- + | + | Optionally override the session-level lock wait timeout before executing + | the transaction. When set to a positive integer the helper issues: + | "SET SESSION innodb_lock_wait_timeout = {seconds}" on the active + | connection prior to each attempt. Set to null to leave the database + | default untouched. + | + */ + + 'lock_wait_timeout_seconds' => env('DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT', 50), + 'log_file_name' => env('DB_TRANSACTION_RETRY_LOG_FILE', 'database/transaction-retries'), /* @@ -34,8 +49,6 @@ 'logging' => [ 'channel' => env('DB_TRANSACTION_RETRY_LOG_CHANNEL'), - 'config' => null, - 'levels' => [ 'success' => env('DB_TRANSACTION_RETRY_LOG_SUCCESS_LEVEL', 'warning'), 'failure' => env('DB_TRANSACTION_RETRY_LOG_FAILURE_LEVEL', 'error'), @@ -60,6 +73,7 @@ 'driver_error_codes' => [ 1213, // MySQL deadlock + //1205, // MySQL lock wait timeout ], 'classes' => [], diff --git a/src/Services/TransactionRetrier.php b/src/Services/TransactionRetrier.php index 28d1eab..f15033c 100644 --- a/src/Services/TransactionRetrier.php +++ b/src/Services/TransactionRetrier.php @@ -50,6 +50,8 @@ public static function runWithRetry( $shouldRetryError = false; try { + static::applyLockWaitTimeout($config); + // Expose the transaction label if the app wants to read it during the callback. $trxLabel === '' || app()->instance('tx.label', $trxLabel); @@ -296,4 +298,46 @@ protected static function normalizeLogLevel(?string $level, string $fallback): s return $candidate !== '' ? $candidate : $fallback; } + + protected static function applyLockWaitTimeout(array $config): void + { + $seconds = $config['lock_wait_timeout_seconds'] ?? null; + + if (! static::isLockWaitRetryEnabled($config)) { + return; + } + + if (is_null($seconds)) { + return; + } + + if (is_string($seconds) && $seconds === '') { + return; + } + + $seconds = (int) $seconds; + + if ($seconds < 1) { + return; + } + + try { + DB::statement('SET SESSION innodb_lock_wait_timeout = ?', [$seconds]); + } catch (Throwable) { + // Silently ignore when the underlying driver does not support this option. + } + } + + 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); + } } diff --git a/tests/Unit/DBTransactionRetryHelperTest.php b/tests/Unit/DBTransactionRetryHelperTest.php index 1c2a9e0..3495ef4 100644 --- a/tests/Unit/DBTransactionRetryHelperTest.php +++ b/tests/Unit/DBTransactionRetryHelperTest.php @@ -110,6 +110,74 @@ function sleep(int $seconds): void expect(SleepSpy::$delays)->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', + 7 + ); + + Container::getInstance()->make('config')->set( + 'database-transaction-retry.retryable_exceptions.driver_error_codes', + [1205] + ); + + $attempts = 0; + + $result = TransactionRetrier::runWithRetry(function () use (&$attempts) { + $attempts++; + + if ($attempts === 1) { + throw makeQueryException(1205, 'HY000'); + } + + return 'done'; + }, maxRetries: 3, retryDelay: 1, trxLabel: 'lock-wait'); + + expect($result)->toBe('done'); + expect($this->database->transactionCalls)->toBe(2); + expect(SleepSpy::$delays)->toHaveCount(1); + expect($this->database->statementCalls)->toHaveCount(2); + + [$statement, $bindings] = $this->database->statementCalls[0]; + expect($statement)->toBe('SET SESSION innodb_lock_wait_timeout = ?'); + expect($bindings)->toBe([7]); + + $record = $this->logManager->records[0]; + expect($record['context']['driverCode'])->toBe(1205); + expect($record['context']['sqlState'])->toBe('HY000'); + expect($record['message'])->toContain('Driver 1205'); + expect($record['message'])->toContain('SQLSTATE HY000'); +}); + +test('does not change session timeout when lock wait retry disabled', function (): void { + Container::getInstance()->make('config')->set( + 'database-transaction-retry.lock_wait_timeout_seconds', + 9 + ); + + 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', + [] + ); + + try { + TransactionRetrier::runWithRetry(function (): void { + throw makeQueryException(1205, 'HY000'); + }, maxRetries: 2, retryDelay: 1); + + $this->fail('Expected QueryException was not thrown.'); + } catch (QueryException $th) { + expect($th->errorInfo[1])->toBe(1205); + } + + 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', @@ -167,10 +235,18 @@ function sleep(int $seconds): void expect(array_key_exists('sqlState', $record['context']))->toBeFalse(); }); -function makeQueryException(int $driverCode, int $sqlState = 40001): QueryException +function makeQueryException(int $driverCode, string|int $sqlState = 40001): QueryException { - $sqlStateString = str_pad((string) $sqlState, 5, '0', STR_PAD_LEFT); - $pdo = new \PDOException('SQLSTATE[' . $sqlStateString . ']: Driver error', $sqlState); + $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, $driverCode, 'Driver error']; return new QueryException( @@ -188,6 +264,8 @@ final class CustomRetryException extends \RuntimeException final class FakeDatabaseManager { public int $transactionCalls = 0; + /** @var list */ + public array $statementCalls = []; private FakeConnection $connection; public function __construct(?FakeConnection $connection = null) @@ -206,11 +284,25 @@ public function connection(?string $name = null): FakeConnection { return $this->connection; } + + public function statement(string $query, array $bindings = []): bool + { + $this->statementCalls[] = [$query, $bindings]; + + return $this->connection()->statement($query, $bindings); + } + + public function getConnection(): FakeConnection + { + return $this->connection; + } } final class FakeConnection { private FakeQueryGrammar $grammar; + /** @var list */ + public array $statements = []; public function __construct(?FakeQueryGrammar $grammar = null) { @@ -221,6 +313,13 @@ public function getQueryGrammar(): FakeQueryGrammar { return $this->grammar; } + + public function statement(string $query, array $bindings = []): bool + { + $this->statements[] = [$query, $bindings]; + + return true; + } } final class FakeQueryGrammar From 927ccb31e70823b0c5765e97e092e3a9a78ab33c Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Tue, 21 Oct 2025 21:41:34 +0300 Subject: [PATCH 3/4] Updates dependencies and config for Laravel 12 support Allows support for Laravel 12 by updating the framework dependency in composer.json. Updates the composer.lock file to reflect the new dependencies. Removes lock wait timeout from top level config and moves it to the "Lock Wait Timeout" section. --- composer.json | 2 +- composer.lock | 320 ++++++++++++++++---------- config/database-transaction-retry.php | 36 +-- 3 files changed, 221 insertions(+), 137 deletions(-) diff --git a/composer.json b/composer.json index 5256d78..5e55370 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "minimum-stability": "stable", "require": { "php": "^8.2", - "laravel/framework": "^11.0" + "laravel/framework": "^11.0 || ^12.0" }, "license": [ "MIT" diff --git a/composer.lock b/composer.lock index c70b80f..2125ee2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "44f287b86eee043f0d40c2975a09c133", + "content-hash": "aca48630e0c6f79b93cfb34a99ccacbb", "packages": [ { "name": "brick/math", @@ -1055,20 +1055,20 @@ }, { "name": "laravel/framework", - "version": "v11.46.1", + "version": "v12.35.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "5fd457f807570a962a53b403b1346efe4cc80bb8" + "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/5fd457f807570a962a53b403b1346efe4cc80bb8", - "reference": "5fd457f807570a962a53b403b1346efe4cc80bb8", + "url": "https://api.github.com/repos/laravel/framework/zipball/9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef", + "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef", "shasum": "" }, "require": { - "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -1083,32 +1083,34 @@ "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", + "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", "league/commonmark": "^2.7", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", "monolog/monolog": "^3.0", - "nesbot/carbon": "^2.72.6|^3.8.4", + "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^7.0.3", - "symfony/error-handler": "^7.0.3", - "symfony/finder": "^7.0.3", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", "symfony/http-foundation": "^7.2.0", - "symfony/http-kernel": "^7.0.3", - "symfony/mailer": "^7.0.3", - "symfony/mime": "^7.0.3", - "symfony/polyfill-php83": "^1.31", - "symfony/process": "^7.0.3", - "symfony/routing": "^7.0.3", - "symfony/uid": "^7.0.3", - "symfony/var-dumper": "^7.0.3", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" @@ -1140,6 +1142,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", "illuminate/mail": "self.version", @@ -1172,17 +1175,18 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^9.16.1", - "pda/pheanstalk": "^5.0.6", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.7.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.5.35|^11.3.6|^12.0.1", - "predis/predis": "^2.3", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0", - "symfony/cache": "^7.0.3", - "symfony/http-client": "^7.0.3", - "symfony/psr-http-message-bridge": "^7.0.3", - "symfony/translation": "^7.0.3" + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", @@ -1197,7 +1201,7 @@ "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", - "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", @@ -1208,22 +1212,22 @@ "mockery/mockery": "Required to use mocking (^1.6).", "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", - "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.3.6|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^7.0).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)." + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "11.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -1266,7 +1270,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-09-30T14:51:32+00:00" + "time": "2025-10-21T15:15:41+00:00" }, { "name": "laravel/prompts", @@ -4642,6 +4646,166 @@ ], "time": "2025-07-08T02:45:35+00:00" }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.33.0", @@ -9539,86 +9703,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php84", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-06-24T13:30:11+00:00" - }, { "name": "symfony/stopwatch", "version": "v7.3.0", diff --git a/config/database-transaction-retry.php b/config/database-transaction-retry.php index d21d942..0021ce6 100644 --- a/config/database-transaction-retry.php +++ b/config/database-transaction-retry.php @@ -16,23 +16,6 @@ 'retry_delay' => (int) env('DB_TRANSACTION_RETRY_DELAY', 2), - /* - |-------------------------------------------------------------------------- - | Lock Wait Timeout - |-------------------------------------------------------------------------- - | - | Optionally override the session-level lock wait timeout before executing - | the transaction. When set to a positive integer the helper issues: - | "SET SESSION innodb_lock_wait_timeout = {seconds}" on the active - | connection prior to each attempt. Set to null to leave the database - | default untouched. - | - */ - - 'lock_wait_timeout_seconds' => env('DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT', 50), - - 'log_file_name' => env('DB_TRANSACTION_RETRY_LOG_FILE', 'database/transaction-retries'), - /* |-------------------------------------------------------------------------- | Logging @@ -46,6 +29,8 @@ | */ + 'log_file_name' => env('DB_TRANSACTION_RETRY_LOG_FILE', 'database/transaction-retries'), + 'logging' => [ 'channel' => env('DB_TRANSACTION_RETRY_LOG_CHANNEL'), @@ -73,9 +58,24 @@ 'driver_error_codes' => [ 1213, // MySQL deadlock - //1205, // MySQL lock wait timeout + // 1205, // MySQL lock wait timeout ], 'classes' => [], ], + + /* + |-------------------------------------------------------------------------- + | Lock Wait Timeout + |-------------------------------------------------------------------------- + | + | Optionally override the session-level lock wait timeout before executing + | the transaction. When set to a positive integer the helper issues: + | "SET SESSION innodb_lock_wait_timeout = {seconds}" on the active + | connection prior to each attempt. Set to null to leave the database + | default untouched. + | + */ + + 'lock_wait_timeout_seconds' => env('DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT', 50), ]; From e53ddbe84cb5f029f4de6772434ace6177a01955 Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Wed, 22 Oct 2025 19:05:35 +0300 Subject: [PATCH 4/4] php cs fixer --- .gitignore | 4 +--- .php-cs-fixer.cache | 2 +- src/Support/TransactionRetryLogWriter.php | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3932318..849579f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,4 @@ vendor/ .idea/ .vscode/ -.DS_Store - -.php-cs-fixer.cache +.DS_Store \ No newline at end of file diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 3b8f5a5..f3e784c 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\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"7d99d773c44861e3f12524a5119f5240","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"149e31651f0e22a15a7df90c9edb5baa","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"07bb6b8e6c8b3ce61a7e67f128f12f4a","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}} \ 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\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"7d99d773c44861e3f12524a5119f5240","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"07bb6b8e6c8b3ce61a7e67f128f12f4a","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}} \ No newline at end of file diff --git a/src/Support/TransactionRetryLogWriter.php b/src/Support/TransactionRetryLogWriter.php index 19bbb43..61e3063 100644 --- a/src/Support/TransactionRetryLogWriter.php +++ b/src/Support/TransactionRetryLogWriter.php @@ -28,7 +28,7 @@ public static function write(array $payload, string $logFileName, string $level $driverCode = $context['driverCode'] ?? null; $codeParts = []; - $sqlState !== '' && $codeParts[] = 'SQLSTATE ' . $sqlState; + $sqlState !== '' && $codeParts[] = 'SQLSTATE ' . $sqlState; ! is_null($driverCode) && $codeParts[] = 'Driver ' . $driverCode; $exceptionSummary = trim($exceptionClass . (count($codeParts) > 0 ? ' (' . implode(', ', $codeParts) . ')' : ''));