diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 3b5750b0def2..cd497447f71e 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -15,6 +15,8 @@ use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\RetryableTransactionException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Events\Events; use CodeIgniter\I18n\Time; use Exception; @@ -2138,6 +2140,41 @@ public function getLastException(): ?DatabaseException return $this->lastException; } + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + return false; + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return false; + } + + /** + * Creates the appropriate database exception for a native database error. + */ + protected function createDatabaseException( + string $message, + int|string $code = 0, + ?Throwable $previous = null, + ): DatabaseException { + if ($this->isUniqueConstraintViolation($code, $message)) { + return new UniqueConstraintViolationException($message, $code, $previous); + } + + if ($this->isRetryableTransactionErrorCode($code)) { + return new RetryableTransactionException($message, $code, $previous); + } + + return new DatabaseException($message, $code, $previous); + } + /** * Insert ID * 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 8b08652f595a..1d22fe39c2ca 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\LogicException; use mysqli; @@ -98,6 +97,24 @@ class Connection extends BaseConnection */ protected bool $strictOn = false; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + // ER_DUP_ENTRY: duplicate key value. + return $code === 1062; + } + + /** + * 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. * @@ -317,10 +334,7 @@ protected function execute(string $sql) 'trace' => render_backtrace($e->getTrace()), ]); - // 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); + $exception = $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 2e28ddca501f..8ed4406c7d11 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\Query; use CodeIgniter\Database\TableName; use ErrorException; @@ -115,6 +114,23 @@ class Connection extends BaseConnection */ public $lastInsertedTableName; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + // ORA-00001: unique constraint violated. + return $code === 1; + } + + /** + * 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. */ @@ -240,11 +256,8 @@ protected function execute(string $sql) $result = oci_execute($this->stmtId, $this->commitMode) ? $this->stmtId : false; if ($result === false) { - // ORA-00001: unique constraint violated $error = $this->error(); - $exception = $error['code'] === 1 - ? new UniqueConstraintViolationException((string) $error['message'], $error['code']) - : new DatabaseException((string) $error['message'], $error['code']); + $exception = $this->createDatabaseException((string) $error['message'], $error['code']); if ($this->DBDebug) { throw $exception; @@ -272,11 +285,8 @@ protected function execute(string $sql) 'trace' => render_backtrace($trace), ]); - // ORA-00001: unique constraint violated $error = $this->error(); - $exception = $error['code'] === 1 - ? new UniqueConstraintViolationException((string) $error['message'], $error['code'], $e) - : new DatabaseException((string) $error['message'], $error['code'], $e); + $exception = $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 e2e796e209c6..881513bd7cca 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\RawSql; use CodeIgniter\Database\TableName; use ErrorException; @@ -63,6 +62,22 @@ class Connection extends BaseConnection */ private ?PgSqlResult $lastFailedResult = null; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + return $code === '23505'; + } + + /** + * 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. * @@ -272,9 +287,7 @@ protected function execute(string $sql) 'trace' => render_backtrace($trace), ]); - $exception = $sqlstate === '23505' - ? new UniqueConstraintViolationException($message, $sqlstate) - : new DatabaseException($message, $sqlstate); + $exception = $this->createDatabaseException($message, $sqlstate); if ($this->DBDebug) { throw $exception; diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 5eacbf6c616e..813a4d49e2a5 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\TableName; use stdClass; @@ -90,6 +89,44 @@ class Connection extends BaseConnection */ protected $_reserved_identifiers = ['*']; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS); + if (! is_array($errors)) { + return false; + } + + foreach ($errors as $error) { + // SQLSTATE 23000 (integrity constraint violation) with SQL Server error + // 2627 (UNIQUE CONSTRAINT or PRIMARY KEY violation) or 2601 (UNIQUE INDEX violation). + if (($error['SQLSTATE'] ?? '') === '23000' + && in_array($error['code'] ?? 0, [2627, 2601], true)) { + return true; + } + } + + return false; + } + + /** + * 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 */ @@ -538,9 +575,7 @@ protected function execute(string $sql) ]); $error = $this->error(); - $exception = $this->isUniqueConstraintViolation() - ? new UniqueConstraintViolationException($message, $error['code']) - : new DatabaseException($message, $error['code']); + $exception = $this->createDatabaseException($message, $error['code']); if ($this->DBDebug) { throw $exception; @@ -552,25 +587,6 @@ protected function execute(string $sql) return $stmt; } - private function isUniqueConstraintViolation(): bool - { - $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS); - if (! is_array($errors)) { - return false; - } - - foreach ($errors as $error) { - // SQLSTATE 23000 (integrity constraint violation) with SQL Server error - // 2627 (UNIQUE CONSTRAINT or PRIMARY KEY violation) or 2601 (UNIQUE INDEX violation). - if (($error['SQLSTATE'] ?? '') === '23000' - && in_array($error['code'] ?? 0, [2627, 2601], true)) { - return true; - } - } - - return false; - } - /** * The name of the platform in use (MySQLi, mssql, etc) */ diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index b65cf62f18b0..8ad9c34ceaa7 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\InvalidArgumentException; use Exception; @@ -67,6 +66,26 @@ class Connection extends BaseConnection */ protected ?int $synchronous = null; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + // SQLite3 reports unique violations in two formats depending on version: + // Modern: "UNIQUE constraint failed: table.column" + // Legacy: "column X is not unique" + return str_contains($message, 'UNIQUE constraint failed') + || str_contains($message, 'is not unique'); + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 5; + } + /** * @return void */ @@ -172,9 +191,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); + $exception = $this->createDatabaseException($e->getMessage(), $error['code'], $e); if ($this->DBDebug) { throw $exception; @@ -186,15 +203,6 @@ protected function execute(string $sql) return false; } - private function isUniqueConstraintViolation(string $message): bool - { - // SQLite3 reports unique violations in two formats depending on version: - // Modern: "UNIQUE constraint failed: table.column" - // Legacy: "column X is not unique" - return str_contains($message, 'UNIQUE constraint failed') - || str_contains($message, 'is not unique'); - } - /** * Returns the total number of rows affected by this query. */ diff --git a/tests/system/Database/RetryableTransactionExceptionTest.php b/tests/system/Database/RetryableTransactionExceptionTest.php new file mode 100644 index 000000000000..1276832463b0 --- /dev/null +++ b/tests/system/Database/RetryableTransactionExceptionTest.php @@ -0,0 +1,221 @@ + + * + * 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\RetryableTransactionException; +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 ReflectionMethod; + +/** + * @internal + */ +#[Group('Others')] +final class RetryableTransactionExceptionTest extends CIUnitTestCase +{ + #[DataProvider('provideCreatesUniqueConstraintViolationExceptions')] + public function testCreatesUniqueConstraintViolationExceptions( + BaseConnection $db, + int|string $code, + string $message, + ): void { + $exception = self::createDatabaseException($db, $message, $code); + + $this->assertInstanceOf(UniqueConstraintViolationException::class, $exception); + $this->assertSame($code, $exception->getDatabaseCode()); + } + + /** + * @return iterable + */ + public static function provideCreatesUniqueConstraintViolationExceptions(): iterable + { + yield 'MySQLi duplicate key' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1062, + 'Duplicate entry.', + ]; + + yield 'Postgre unique violation' => [ + self::connection(PostgreConnection::class, 'Postgre'), + '23505', + 'Unique violation.', + ]; + + yield 'SQLite unique constraint' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'UNIQUE constraint failed: table.column', + ]; + + yield 'SQLite legacy unique constraint' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'column email is not unique', + ]; + + if (defined('OCI_COMMIT_ON_SUCCESS')) { + yield 'OCI8 unique constraint' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 1, + 'Unique constraint violated.', + ]; + } + } + + #[DataProvider('provideCreatesRetryableTransactionExceptions')] + public function testCreatesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void + { + $exception = self::createDatabaseException($db, 'Retryable transaction failure.', $code); + + $this->assertInstanceOf(RetryableTransactionException::class, $exception); + $this->assertSame($code, $exception->getDatabaseCode()); + } + + /** + * @return iterable + */ + public static function provideCreatesRetryableTransactionExceptions(): 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('provideCreatesBaseDatabaseExceptionsForNonRetryableErrors')] + public function testCreatesBaseDatabaseExceptionsForNonRetryableErrors(BaseConnection $db, int|string $code): void + { + $exception = self::createDatabaseException($db, 'Non-retryable transaction failure.', $code); + + $this->assertNotInstanceOf(RetryableTransactionException::class, $exception); + } + + /** + * @return iterable + */ + public static function provideCreatesBaseDatabaseExceptionsForNonRetryableErrors(): 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 testQueryThrowsRetryableTransactionExceptionFromDriverExecutionPath(): void + { + $db = $this->getMockBuilder(MySQLiConnection::class) + ->setConstructorArgs([self::config('MySQLi')]) + ->onlyMethods(['connect', 'execute']) + ->getMock(); + + $db->method('connect')->willReturn(mysqli_init()); + $db->method('execute')->willThrowException( + self::createDatabaseException($db, 'Deadlock found when trying to get lock.', 1213), + ); + + $this->expectException(RetryableTransactionException::class); + + $db->query('SELECT * FROM test'); + } + + /** + * @param class-string $connectionClass + */ + private static function connection(string $connectionClass, string $driver): BaseConnection + { + return new $connectionClass(self::config($driver)); + } + + /** + * @return array + */ + private static function config(string $driver): array + { + return [ + 'DSN' => '', + 'hostname' => 'localhost', + 'username' => '', + 'password' => '', + 'database' => 'test', + 'DBDriver' => $driver, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + '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 60fc45056d04..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()``, 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``. @@ -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 ``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. Query Builder diff --git a/user_guide_src/source/database/transactions.rst b/user_guide_src/source/database/transactions.rst index f60af5ce489d..1d3387d5768d 100644 --- a/user_guide_src/source/database/transactions.rst +++ b/user_guide_src/source/database/transactions.rst @@ -82,6 +82,34 @@ 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. 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 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. + +When ``DBDebug`` is ``false`` and a failed query returns ``false`` instead of +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 new file mode 100644 index 000000000000..b5a6550cd6fb --- /dev/null +++ b/user_guide_src/source/database/transactions/015.php @@ -0,0 +1,14 @@ +transException(true)->transaction(static function ($db) { + $db->table('orders')->insert($order); + + return $db->insertID(); + }); +} catch (RetryableTransactionException $e) { + // Retry the whole transaction according to your application's policy. + throw $e; +}