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; +}