Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions system/Database/BaseConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
7 changes: 7 additions & 0 deletions system/Database/ConnectionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

namespace CodeIgniter\Database;

use Throwable;

/**
* @template TConnection
* @template TResult
Expand Down Expand Up @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions system/Database/MySQLi/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
8 changes: 8 additions & 0 deletions system/Database/OCI8/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
8 changes: 8 additions & 0 deletions system/Database/Postgre/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
16 changes: 16 additions & 0 deletions system/Database/SQLSRV/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
8 changes: 8 additions & 0 deletions system/Database/SQLite3/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
150 changes: 150 additions & 0 deletions tests/system/Database/RetryableTransactionExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<string, array{BaseConnection, int|string}>
*/
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<string, array{BaseConnection, int|string}>
*/
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<BaseConnection> $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' => [],
]);
}
}
5 changes: 3 additions & 2 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions user_guide_src/source/database/transactions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
===========

Expand Down
17 changes: 17 additions & 0 deletions user_guide_src/source/database/transactions/015.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

use CodeIgniter\Database\Exceptions\DatabaseException;

try {
$result = $db->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;
}
Loading