diff --git a/.gitignore b/.gitignore index b960e00..849579f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ vendor/ .idea/ .vscode/ -.DS_Store +.DS_Store \ No newline at end of file diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index cf130ad..f3e784c 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":"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/README.md b/README.md index 8e72f6f..bd7f709 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 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}`. -- 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,44 @@ 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`). +- `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.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` 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 -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` (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, driver error `1205`, application exceptions) is surfaced immediately without logging or sleeping. +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. -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. +## 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`, `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` 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 +121,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 +135,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..5e55370 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,18 @@ { - "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": { "php": "^8.2", - "laravel/framework": "^11.0" + "laravel/framework": "^11.0 || ^12.0" }, "license": [ "MIT" ], "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..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": "b6dd3735e10c02ef8c3fcd8383840434", + "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 new file mode 100644 index 0000000..0021ce6 --- /dev/null +++ b/config/database-transaction-retry.php @@ -0,0 +1,81 @@ + (int) env('DB_TRANSACTION_RETRY_MAX_RETRIES', 3), + + 'retry_delay' => (int) env('DB_TRANSACTION_RETRY_DELAY', 2), + + /* + |-------------------------------------------------------------------------- + | 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. + | + */ + + 'log_file_name' => env('DB_TRANSACTION_RETRY_LOG_FILE', 'database/transaction-retries'), + + 'logging' => [ + 'channel' => env('DB_TRANSACTION_RETRY_LOG_CHANNEL'), + + '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 + // 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), +]; 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..f15033c --- /dev/null +++ b/src/Services/TransactionRetrier.php @@ -0,0 +1,343 @@ +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; + } + + 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/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..3495ef4 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,10 +110,143 @@ function sleep(int $seconds): void expect(SleepSpy::$delays)->toBe([]); }); -function makeQueryException(int $driverCode, int $sqlState = 40001): QueryException +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', + [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, 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( @@ -117,9 +257,15 @@ function makeQueryException(int $driverCode, int $sqlState = 40001): QueryExcept ); } +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) @@ -138,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) { @@ -153,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 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, '/'); }