From 3fe354db994bea34d24d0e3ebffead65430277af Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 6 May 2026 18:31:44 +0200 Subject: [PATCH 1/2] feat(database): classify retryable transaction exceptions Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseConnection.php | 17 ++ system/Database/ConnectionInterface.php | 7 + system/Database/MySQLi/Connection.php | 9 ++ system/Database/OCI8/Connection.php | 8 + system/Database/Postgre/Connection.php | 8 + system/Database/SQLSRV/Connection.php | 16 ++ system/Database/SQLite3/Connection.php | 8 + .../RetryableTransactionExceptionTest.php | 150 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 5 +- .../source/database/transactions.rst | 26 +++ .../source/database/transactions/015.php | 17 ++ 11 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 tests/system/Database/RetryableTransactionExceptionTest.php create mode 100644 user_guide_src/source/database/transactions/015.php diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 3b5750b0def2..2ac3a670a828 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -2138,6 +2138,23 @@ public function getLastException(): ?DatabaseException return $this->lastException; } + /** + * Checks whether the exception represents a retryable transaction failure. + */ + public function isRetryableTransactionException(Throwable $exception): bool + { + return $exception instanceof DatabaseException + && $this->isRetryableTransactionErrorCode($exception->getDatabaseCode()); + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return false; + } + /** * Insert ID * diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 6815c433a5e3..7ba9b6746b42 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Database; +use Throwable; + /** * @template TConnection * @template TResult @@ -149,6 +151,11 @@ public function afterRollback(callable $callback): static; */ public function transaction(callable $callback): mixed; + /** + * Checks whether the exception represents a retryable transaction failure. + */ + public function isRetryableTransactionException(Throwable $exception): bool; + /** * Returns an instance of the query builder for this connection. * diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 8b08652f595a..d9771cffe12c 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -98,6 +98,15 @@ class Connection extends BaseConnection */ protected bool $strictOn = false; + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + // ER_LOCK_DEADLOCK: InnoDB rolls back the full transaction. + return $code === 1213; + } + /** * Connect to the database. * diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 2e28ddca501f..2a1ba9d6b761 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -115,6 +115,14 @@ class Connection extends BaseConnection */ public $lastInsertedTableName; + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return in_array($code, [60, 8177], true); + } + /** * confirm DSN format. */ diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index e2e796e209c6..4f5ce4439b04 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -63,6 +63,14 @@ class Connection extends BaseConnection */ private ?PgSqlResult $lastFailedResult = null; + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return in_array($code, ['40001', '40P01'], true); + } + /** * Connect to the database. * diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 5eacbf6c616e..28605129130a 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -90,6 +90,22 @@ class Connection extends BaseConnection */ protected $_reserved_identifiers = ['*']; + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + $vendorCode = (string) (is_string($code) && str_contains($code, '/') + ? substr($code, strrpos($code, '/') + 1) + : $code); + + if (preg_match('/^\d+$/', $vendorCode) !== 1) { + return false; + } + + return in_array((int) $vendorCode, [1205, 3960], true); + } + /** * Class constructor */ diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index b65cf62f18b0..daf1d079dcca 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -67,6 +67,14 @@ class Connection extends BaseConnection */ protected ?int $synchronous = null; + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 5; + } + /** * @return void */ diff --git a/tests/system/Database/RetryableTransactionExceptionTest.php b/tests/system/Database/RetryableTransactionExceptionTest.php new file mode 100644 index 000000000000..be5b9989d9a1 --- /dev/null +++ b/tests/system/Database/RetryableTransactionExceptionTest.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; +use CodeIgniter\Database\OCI8\Connection as OCI8Connection; +use CodeIgniter\Database\Postgre\Connection as PostgreConnection; +use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection; +use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; + +/** + * @internal + */ +#[Group('Others')] +final class RetryableTransactionExceptionTest extends CIUnitTestCase +{ + #[DataProvider('provideRecognizesRetryableTransactionExceptions')] + public function testRecognizesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void + { + $exception = new DatabaseException('Retryable transaction failure.', $code); + + $this->assertTrue($db->isRetryableTransactionException($exception)); + } + + /** + * @return iterable + */ + public static function provideRecognizesRetryableTransactionExceptions(): iterable + { + yield 'MySQLi deadlock' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1213]; + + yield 'Postgre serialization failure' => [self::connection(PostgreConnection::class, 'Postgre'), '40001']; + + yield 'Postgre deadlock' => [self::connection(PostgreConnection::class, 'Postgre'), '40P01']; + + yield 'SQLite busy' => [self::connection(SQLite3Connection::class, 'SQLite3'), 5]; + + yield 'SQLSRV deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001/1205']; + + yield 'SQLSRV vendor deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 1205]; + + yield 'SQLSRV snapshot isolation conflict' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HY000/3960']; + + if (defined('OCI_COMMIT_ON_SUCCESS')) { + yield 'OCI8 deadlock' => [self::connection(OCI8Connection::class, 'OCI8'), 60]; + + yield 'OCI8 serialization failure' => [self::connection(OCI8Connection::class, 'OCI8'), 8177]; + } + } + + #[DataProvider('provideRejectsNonRetryableTransactionExceptions')] + public function testRejectsNonRetryableTransactionExceptions(BaseConnection $db, int|string $code): void + { + $exception = new DatabaseException('Non-retryable transaction failure.', $code); + + $this->assertFalse($db->isRetryableTransactionException($exception)); + } + + /** + * @return iterable + */ + public static function provideRejectsNonRetryableTransactionExceptions(): iterable + { + yield 'Base connection default' => [self::connection(MockConnection::class, 'MockDriver'), 1213]; + + yield 'MySQLi lock wait timeout' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1205]; + + yield 'MySQLi duplicate key' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1062]; + + yield 'Postgre unique violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23505']; + + yield 'Postgre exclusion violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23P01']; + + yield 'SQLite locked' => [self::connection(SQLite3Connection::class, 'SQLite3'), 6]; + + yield 'SQLite busy snapshot extended code' => [self::connection(SQLite3Connection::class, 'SQLite3'), 517]; + + yield 'SQLite constraint' => [self::connection(SQLite3Connection::class, 'SQLite3'), 19]; + + yield 'SQLSRV lock timeout' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HYT00/1222']; + + yield 'SQLSRV SQLSTATE without vendor code' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001']; + + yield 'SQLSRV unique constraint' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2627']; + + yield 'SQLSRV unique index' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2601']; + + if (defined('OCI_COMMIT_ON_SUCCESS')) { + yield 'OCI8 resource busy' => [self::connection(OCI8Connection::class, 'OCI8'), 54]; + + yield 'OCI8 unique constraint' => [self::connection(OCI8Connection::class, 'OCI8'), 1]; + } + } + + public function testRejectsNonDatabaseExceptions(): void + { + $db = self::connection(MySQLiConnection::class, 'MySQLi'); + + $this->assertFalse($db->isRetryableTransactionException(new RuntimeException('Not a database exception.'))); + } + + public function testRejectsUniqueConstraintViolationExceptions(): void + { + $db = self::connection(MySQLiConnection::class, 'MySQLi'); + + $this->assertFalse($db->isRetryableTransactionException( + new UniqueConstraintViolationException('Duplicate key.', 1062), + )); + } + + /** + * @param class-string $connectionClass + */ + private static function connection(string $connectionClass, string $driver): BaseConnection + { + return new $connectionClass([ + 'DSN' => '', + 'hostname' => 'localhost', + 'username' => '', + 'password' => '', + 'database' => 'test', + 'DBDriver' => $driver, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'failover' => [], + ]); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 60fc45056d04..697b7da26e2b 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -43,7 +43,7 @@ Interface Changes **NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods or method changes to ensure compatibility. -- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, and ``transaction()`` methods. +- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, ``isRetryableTransactionException()``, and ``transaction()`` methods. - **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. @@ -201,8 +201,9 @@ Database ======== - Added ``afterCommit()`` and ``afterRollback()`` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. See :ref:`transactions-transaction-callbacks`. -- Added the ``transaction()`` method to database connections to run a callback inside a transaction. See :ref:`transactions-closure`. - Added ``inTransaction()`` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See :ref:`transactions-checking-transaction-state`. +- Added ``isRetryableTransactionException()`` to database connections to identify driver-specific retryable transaction failures such as deadlocks and serialization failures. See :ref:`transactions-retryable-exceptions`. +- Added the ``transaction()`` method to database connections to run a callback inside a transaction. See :ref:`transactions-closure`. - Added ``trustServerCertificate`` option to ``SQLSRV`` database connections in ``Config\Database``. Set it to ``true`` to trust the server certificate without CA validation when using encrypted connections. Query Builder diff --git a/user_guide_src/source/database/transactions.rst b/user_guide_src/source/database/transactions.rst index f60af5ce489d..6ad16b39f331 100644 --- a/user_guide_src/source/database/transactions.rst +++ b/user_guide_src/source/database/transactions.rst @@ -82,6 +82,32 @@ Callbacks registered with ``afterCommit()`` or ``afterRollback()`` inside the transaction callback follow the same rules as other transaction callbacks: they run only after the outermost transaction commits or rolls back. +.. _transactions-retryable-exceptions: + +Classifying Retryable Transaction Failures +========================================== + +.. versionadded:: 4.8.0 + +Some database engines report transaction failures that may succeed when the +entire transaction is attempted again, such as deadlocks or serialization +failures. The ``isRetryableTransactionException()`` method checks whether a +``DatabaseException`` represents one of these driver-specific failures so you +can decide how your application should respond: + +.. literalinclude:: transactions/015.php + +This method is only a classifier. It does not retry the transaction +automatically. If you retry, run the whole transaction again. Avoid +non-transactional side effects inside transaction bodies that may be retried. +For side effects such as queued jobs, emails, cache invalidation, or external +API calls, register them with ``afterCommit()`` so they run only after the +transaction commits. + +When ``DBDebug`` is ``false`` and a failed query returns ``false`` instead of +throwing, inspect ``getLastException()`` immediately after the failed operation +and pass that exception to ``isRetryableTransactionException()``. + Strict Mode =========== diff --git a/user_guide_src/source/database/transactions/015.php b/user_guide_src/source/database/transactions/015.php new file mode 100644 index 000000000000..11a08f9b0859 --- /dev/null +++ b/user_guide_src/source/database/transactions/015.php @@ -0,0 +1,17 @@ +transException(true)->transaction(static function ($db) { + $db->table('orders')->insert($order); + + return $db->insertID(); + }); +} catch (DatabaseException $e) { + if ($db->isRetryableTransactionException($e)) { + // Retry the whole transaction according to your application's policy. + } + + throw $e; +} From 919c234eeb7e65d09baeb0c7c448f6a4dc7d27a0 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Fri, 8 May 2026 00:53:44 +0200 Subject: [PATCH 2/2] refactor(database): use retryable transaction exception - Replace the public retryable transaction classifier with a named exception - Classify retryable driver errors through the database exception factory - Update docs and tests for exception-based retry handling Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseConnection.php | 23 ++++--- system/Database/ConnectionInterface.php | 7 -- .../RetryableTransactionException.php | 18 +++++ system/Database/MySQLi/Connection.php | 2 +- system/Database/OCI8/Connection.php | 4 +- system/Database/Postgre/Connection.php | 2 +- system/Database/SQLSRV/Connection.php | 2 +- system/Database/SQLite3/Connection.php | 2 +- .../RetryableTransactionExceptionTest.php | 68 ++++++++++++------- user_guide_src/source/changelogs/v4.8.0.rst | 4 +- .../source/database/transactions.rst | 22 +++--- .../source/database/transactions/015.php | 9 +-- 12 files changed, 100 insertions(+), 63 deletions(-) create mode 100644 system/Database/Exceptions/RetryableTransactionException.php diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 2ac3a670a828..10bd190ffdef 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -15,6 +15,7 @@ use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\RetryableTransactionException; use CodeIgniter\Events\Events; use CodeIgniter\I18n\Time; use Exception; @@ -2139,20 +2140,26 @@ public function getLastException(): ?DatabaseException } /** - * Checks whether the exception represents a retryable transaction failure. + * Checks whether the native database code represents a retryable transaction failure. */ - public function isRetryableTransactionException(Throwable $exception): bool + protected function isRetryableTransactionErrorCode(int|string $code): bool { - return $exception instanceof DatabaseException - && $this->isRetryableTransactionErrorCode($exception->getDatabaseCode()); + return false; } /** - * Checks whether the native database code represents a retryable transaction failure. + * Creates the appropriate database exception for a native database error. */ - protected function isRetryableTransactionErrorCode(int|string $code): bool - { - return false; + protected function createDatabaseException( + string $message, + int|string $code = 0, + ?Throwable $previous = null, + ): DatabaseException { + if ($this->isRetryableTransactionErrorCode($code)) { + return new RetryableTransactionException($message, $code, $previous); + } + + return new DatabaseException($message, $code, $previous); } /** diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 7ba9b6746b42..6815c433a5e3 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -13,8 +13,6 @@ namespace CodeIgniter\Database; -use Throwable; - /** * @template TConnection * @template TResult @@ -151,11 +149,6 @@ public function afterRollback(callable $callback): static; */ public function transaction(callable $callback): mixed; - /** - * Checks whether the exception represents a retryable transaction failure. - */ - public function isRetryableTransactionException(Throwable $exception): bool; - /** * Returns an instance of the query builder for this connection. * diff --git a/system/Database/Exceptions/RetryableTransactionException.php b/system/Database/Exceptions/RetryableTransactionException.php new file mode 100644 index 000000000000..770a506b44a6 --- /dev/null +++ b/system/Database/Exceptions/RetryableTransactionException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +class RetryableTransactionException extends DatabaseException +{ +} diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index d9771cffe12c..4d3fe7e5be39 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -329,7 +329,7 @@ protected function execute(string $sql) // MySQL error 1062: ER_DUP_ENTRY – duplicate key value $exception = $e->getCode() === 1062 ? new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e) - : new DatabaseException($e->getMessage(), $e->getCode(), $e); + : $this->createDatabaseException($e->getMessage(), $e->getCode(), $e); if ($this->DBDebug) { throw $exception; diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 2a1ba9d6b761..c7d75ce170ca 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -252,7 +252,7 @@ protected function execute(string $sql) $error = $this->error(); $exception = $error['code'] === 1 ? new UniqueConstraintViolationException((string) $error['message'], $error['code']) - : new DatabaseException((string) $error['message'], $error['code']); + : $this->createDatabaseException((string) $error['message'], $error['code']); if ($this->DBDebug) { throw $exception; @@ -284,7 +284,7 @@ protected function execute(string $sql) $error = $this->error(); $exception = $error['code'] === 1 ? new UniqueConstraintViolationException((string) $error['message'], $error['code'], $e) - : new DatabaseException((string) $error['message'], $error['code'], $e); + : $this->createDatabaseException((string) $error['message'], $error['code'], $e); if ($this->DBDebug) { throw $exception; diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 4f5ce4439b04..08858bab36f4 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -282,7 +282,7 @@ protected function execute(string $sql) $exception = $sqlstate === '23505' ? new UniqueConstraintViolationException($message, $sqlstate) - : new DatabaseException($message, $sqlstate); + : $this->createDatabaseException($message, $sqlstate); if ($this->DBDebug) { throw $exception; diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 28605129130a..a4528d839e78 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -556,7 +556,7 @@ protected function execute(string $sql) $error = $this->error(); $exception = $this->isUniqueConstraintViolation() ? new UniqueConstraintViolationException($message, $error['code']) - : new DatabaseException($message, $error['code']); + : $this->createDatabaseException($message, $error['code']); if ($this->DBDebug) { throw $exception; diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index daf1d079dcca..759814536c6c 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -182,7 +182,7 @@ protected function execute(string $sql) $error = $this->error(); $exception = $this->isUniqueConstraintViolation($e->getMessage()) ? new UniqueConstraintViolationException($e->getMessage(), $error['code'], $e) - : new DatabaseException($e->getMessage(), $error['code'], $e); + : $this->createDatabaseException($e->getMessage(), $error['code'], $e); if ($this->DBDebug) { throw $exception; diff --git a/tests/system/Database/RetryableTransactionExceptionTest.php b/tests/system/Database/RetryableTransactionExceptionTest.php index be5b9989d9a1..6ad7247b06cf 100644 --- a/tests/system/Database/RetryableTransactionExceptionTest.php +++ b/tests/system/Database/RetryableTransactionExceptionTest.php @@ -14,7 +14,7 @@ namespace CodeIgniter\Database; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Database\Exceptions\RetryableTransactionException; use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; use CodeIgniter\Database\OCI8\Connection as OCI8Connection; use CodeIgniter\Database\Postgre\Connection as PostgreConnection; @@ -24,7 +24,7 @@ use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use RuntimeException; +use ReflectionMethod; /** * @internal @@ -32,18 +32,19 @@ #[Group('Others')] final class RetryableTransactionExceptionTest extends CIUnitTestCase { - #[DataProvider('provideRecognizesRetryableTransactionExceptions')] - public function testRecognizesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void + #[DataProvider('provideCreatesRetryableTransactionExceptions')] + public function testCreatesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void { - $exception = new DatabaseException('Retryable transaction failure.', $code); + $exception = self::createDatabaseException($db, 'Retryable transaction failure.', $code); - $this->assertTrue($db->isRetryableTransactionException($exception)); + $this->assertInstanceOf(RetryableTransactionException::class, $exception); + $this->assertSame($code, $exception->getDatabaseCode()); } /** * @return iterable */ - public static function provideRecognizesRetryableTransactionExceptions(): iterable + public static function provideCreatesRetryableTransactionExceptions(): iterable { yield 'MySQLi deadlock' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1213]; @@ -66,18 +67,18 @@ public static function provideRecognizesRetryableTransactionExceptions(): iterab } } - #[DataProvider('provideRejectsNonRetryableTransactionExceptions')] - public function testRejectsNonRetryableTransactionExceptions(BaseConnection $db, int|string $code): void + #[DataProvider('provideCreatesBaseDatabaseExceptionsForNonRetryableErrors')] + public function testCreatesBaseDatabaseExceptionsForNonRetryableErrors(BaseConnection $db, int|string $code): void { - $exception = new DatabaseException('Non-retryable transaction failure.', $code); + $exception = self::createDatabaseException($db, 'Non-retryable transaction failure.', $code); - $this->assertFalse($db->isRetryableTransactionException($exception)); + $this->assertNotInstanceOf(RetryableTransactionException::class, $exception); } /** * @return iterable */ - public static function provideRejectsNonRetryableTransactionExceptions(): iterable + public static function provideCreatesBaseDatabaseExceptionsForNonRetryableErrors(): iterable { yield 'Base connection default' => [self::connection(MockConnection::class, 'MockDriver'), 1213]; @@ -110,20 +111,21 @@ public static function provideRejectsNonRetryableTransactionExceptions(): iterab } } - public function testRejectsNonDatabaseExceptions(): void + public function testQueryThrowsRetryableTransactionExceptionFromDriverExecutionPath(): void { - $db = self::connection(MySQLiConnection::class, 'MySQLi'); + $db = $this->getMockBuilder(MySQLiConnection::class) + ->setConstructorArgs([self::config('MySQLi')]) + ->onlyMethods(['connect', 'execute']) + ->getMock(); - $this->assertFalse($db->isRetryableTransactionException(new RuntimeException('Not a database exception.'))); - } + $db->method('connect')->willReturn(mysqli_init()); + $db->method('execute')->willThrowException( + self::createDatabaseException($db, 'Deadlock found when trying to get lock.', 1213), + ); - public function testRejectsUniqueConstraintViolationExceptions(): void - { - $db = self::connection(MySQLiConnection::class, 'MySQLi'); + $this->expectException(RetryableTransactionException::class); - $this->assertFalse($db->isRetryableTransactionException( - new UniqueConstraintViolationException('Duplicate key.', 1062), - )); + $db->query('SELECT * FROM test'); } /** @@ -131,7 +133,15 @@ public function testRejectsUniqueConstraintViolationExceptions(): void */ private static function connection(string $connectionClass, string $driver): BaseConnection { - return new $connectionClass([ + return new $connectionClass(self::config($driver)); + } + + /** + * @return array + */ + private static function config(string $driver): array + { + return [ 'DSN' => '', 'hostname' => 'localhost', 'username' => '', @@ -145,6 +155,16 @@ private static function connection(string $connectionClass, string $driver): Bas 'encrypt' => false, 'compress' => false, 'failover' => [], - ]); + ]; + } + + private static function createDatabaseException( + BaseConnection $db, + string $message, + int|string $code, + ): DatabaseException { + $method = new ReflectionMethod($db, 'createDatabaseException'); + + return $method->invoke($db, $message, $code); } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 697b7da26e2b..63b7eaa81b30 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -43,7 +43,7 @@ Interface Changes **NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods or method changes to ensure compatibility. -- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, ``isRetryableTransactionException()``, and ``transaction()`` methods. +- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()`` and ``transaction()`` methods. - **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. @@ -202,7 +202,7 @@ Database - Added ``afterCommit()`` and ``afterRollback()`` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. See :ref:`transactions-transaction-callbacks`. - Added ``inTransaction()`` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See :ref:`transactions-checking-transaction-state`. -- Added ``isRetryableTransactionException()`` to database connections to identify driver-specific retryable transaction failures such as deadlocks and serialization failures. See :ref:`transactions-retryable-exceptions`. +- Added ``RetryableTransactionException`` for driver-specific retryable transaction failures such as deadlocks and serialization failures. See :ref:`transactions-retryable-exceptions`. - Added the ``transaction()`` method to database connections to run a callback inside a transaction. See :ref:`transactions-closure`. - Added ``trustServerCertificate`` option to ``SQLSRV`` database connections in ``Config\Database``. Set it to ``true`` to trust the server certificate without CA validation when using encrypted connections. diff --git a/user_guide_src/source/database/transactions.rst b/user_guide_src/source/database/transactions.rst index 6ad16b39f331..1d3387d5768d 100644 --- a/user_guide_src/source/database/transactions.rst +++ b/user_guide_src/source/database/transactions.rst @@ -91,22 +91,24 @@ Classifying Retryable Transaction Failures Some database engines report transaction failures that may succeed when the entire transaction is attempted again, such as deadlocks or serialization -failures. The ``isRetryableTransactionException()`` method checks whether a -``DatabaseException`` represents one of these driver-specific failures so you -can decide how your application should respond: +failures. When a driver classifies a query execution failure as one of these +retryable transaction failures, CodeIgniter throws +``RetryableTransactionException`` so you can decide how your application should +respond: .. literalinclude:: transactions/015.php -This method is only a classifier. It does not retry the transaction +This exception is only a classifier. CodeIgniter does not retry the transaction automatically. If you retry, run the whole transaction again. Avoid -non-transactional side effects inside transaction bodies that may be retried. -For side effects such as queued jobs, emails, cache invalidation, or external -API calls, register them with ``afterCommit()`` so they run only after the -transaction commits. +non-transactional side effects inside transaction bodies that may be retried. For +side effects such as queued jobs, emails, cache invalidation, or external API +calls, register them with ``afterCommit()`` so they run only after the transaction +commits. When ``DBDebug`` is ``false`` and a failed query returns ``false`` instead of -throwing, inspect ``getLastException()`` immediately after the failed operation -and pass that exception to ``isRetryableTransactionException()``. +throwing, inspect ``getLastException()`` immediately after the failed operation. +It will contain the ``RetryableTransactionException`` instance when the driver +classifies the failure as retryable. Strict Mode =========== diff --git a/user_guide_src/source/database/transactions/015.php b/user_guide_src/source/database/transactions/015.php index 11a08f9b0859..b5a6550cd6fb 100644 --- a/user_guide_src/source/database/transactions/015.php +++ b/user_guide_src/source/database/transactions/015.php @@ -1,6 +1,6 @@ transException(true)->transaction(static function ($db) { @@ -8,10 +8,7 @@ return $db->insertID(); }); -} catch (DatabaseException $e) { - if ($db->isRetryableTransactionException($e)) { - // Retry the whole transaction according to your application's policy. - } - +} catch (RetryableTransactionException $e) { + // Retry the whole transaction according to your application's policy. throw $e; }