Skip to content

Commit 3fe354d

Browse files
committed
feat(database): classify retryable transaction exceptions
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 3f91249 commit 3fe354d

11 files changed

Lines changed: 269 additions & 2 deletions

File tree

system/Database/BaseConnection.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,6 +2138,23 @@ public function getLastException(): ?DatabaseException
21382138
return $this->lastException;
21392139
}
21402140

2141+
/**
2142+
* Checks whether the exception represents a retryable transaction failure.
2143+
*/
2144+
public function isRetryableTransactionException(Throwable $exception): bool
2145+
{
2146+
return $exception instanceof DatabaseException
2147+
&& $this->isRetryableTransactionErrorCode($exception->getDatabaseCode());
2148+
}
2149+
2150+
/**
2151+
* Checks whether the native database code represents a retryable transaction failure.
2152+
*/
2153+
protected function isRetryableTransactionErrorCode(int|string $code): bool
2154+
{
2155+
return false;
2156+
}
2157+
21412158
/**
21422159
* Insert ID
21432160
*

system/Database/ConnectionInterface.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
namespace CodeIgniter\Database;
1515

16+
use Throwable;
17+
1618
/**
1719
* @template TConnection
1820
* @template TResult
@@ -149,6 +151,11 @@ public function afterRollback(callable $callback): static;
149151
*/
150152
public function transaction(callable $callback): mixed;
151153

154+
/**
155+
* Checks whether the exception represents a retryable transaction failure.
156+
*/
157+
public function isRetryableTransactionException(Throwable $exception): bool;
158+
152159
/**
153160
* Returns an instance of the query builder for this connection.
154161
*

system/Database/MySQLi/Connection.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ class Connection extends BaseConnection
9898
*/
9999
protected bool $strictOn = false;
100100

101+
/**
102+
* Checks whether the native database code represents a retryable transaction failure.
103+
*/
104+
protected function isRetryableTransactionErrorCode(int|string $code): bool
105+
{
106+
// ER_LOCK_DEADLOCK: InnoDB rolls back the full transaction.
107+
return $code === 1213;
108+
}
109+
101110
/**
102111
* Connect to the database.
103112
*

system/Database/OCI8/Connection.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ class Connection extends BaseConnection
115115
*/
116116
public $lastInsertedTableName;
117117

118+
/**
119+
* Checks whether the native database code represents a retryable transaction failure.
120+
*/
121+
protected function isRetryableTransactionErrorCode(int|string $code): bool
122+
{
123+
return in_array($code, [60, 8177], true);
124+
}
125+
118126
/**
119127
* confirm DSN format.
120128
*/

system/Database/Postgre/Connection.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ class Connection extends BaseConnection
6363
*/
6464
private ?PgSqlResult $lastFailedResult = null;
6565

66+
/**
67+
* Checks whether the native database code represents a retryable transaction failure.
68+
*/
69+
protected function isRetryableTransactionErrorCode(int|string $code): bool
70+
{
71+
return in_array($code, ['40001', '40P01'], true);
72+
}
73+
6674
/**
6775
* Connect to the database.
6876
*

system/Database/SQLSRV/Connection.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ class Connection extends BaseConnection
9090
*/
9191
protected $_reserved_identifiers = ['*'];
9292

93+
/**
94+
* Checks whether the native database code represents a retryable transaction failure.
95+
*/
96+
protected function isRetryableTransactionErrorCode(int|string $code): bool
97+
{
98+
$vendorCode = (string) (is_string($code) && str_contains($code, '/')
99+
? substr($code, strrpos($code, '/') + 1)
100+
: $code);
101+
102+
if (preg_match('/^\d+$/', $vendorCode) !== 1) {
103+
return false;
104+
}
105+
106+
return in_array((int) $vendorCode, [1205, 3960], true);
107+
}
108+
93109
/**
94110
* Class constructor
95111
*/

system/Database/SQLite3/Connection.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ class Connection extends BaseConnection
6767
*/
6868
protected ?int $synchronous = null;
6969

70+
/**
71+
* Checks whether the native database code represents a retryable transaction failure.
72+
*/
73+
protected function isRetryableTransactionErrorCode(int|string $code): bool
74+
{
75+
return $code === 5;
76+
}
77+
7078
/**
7179
* @return void
7280
*/
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Database;
15+
16+
use CodeIgniter\Database\Exceptions\DatabaseException;
17+
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
18+
use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection;
19+
use CodeIgniter\Database\OCI8\Connection as OCI8Connection;
20+
use CodeIgniter\Database\Postgre\Connection as PostgreConnection;
21+
use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection;
22+
use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection;
23+
use CodeIgniter\Test\CIUnitTestCase;
24+
use CodeIgniter\Test\Mock\MockConnection;
25+
use PHPUnit\Framework\Attributes\DataProvider;
26+
use PHPUnit\Framework\Attributes\Group;
27+
use RuntimeException;
28+
29+
/**
30+
* @internal
31+
*/
32+
#[Group('Others')]
33+
final class RetryableTransactionExceptionTest extends CIUnitTestCase
34+
{
35+
#[DataProvider('provideRecognizesRetryableTransactionExceptions')]
36+
public function testRecognizesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void
37+
{
38+
$exception = new DatabaseException('Retryable transaction failure.', $code);
39+
40+
$this->assertTrue($db->isRetryableTransactionException($exception));
41+
}
42+
43+
/**
44+
* @return iterable<string, array{BaseConnection, int|string}>
45+
*/
46+
public static function provideRecognizesRetryableTransactionExceptions(): iterable
47+
{
48+
yield 'MySQLi deadlock' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1213];
49+
50+
yield 'Postgre serialization failure' => [self::connection(PostgreConnection::class, 'Postgre'), '40001'];
51+
52+
yield 'Postgre deadlock' => [self::connection(PostgreConnection::class, 'Postgre'), '40P01'];
53+
54+
yield 'SQLite busy' => [self::connection(SQLite3Connection::class, 'SQLite3'), 5];
55+
56+
yield 'SQLSRV deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001/1205'];
57+
58+
yield 'SQLSRV vendor deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 1205];
59+
60+
yield 'SQLSRV snapshot isolation conflict' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HY000/3960'];
61+
62+
if (defined('OCI_COMMIT_ON_SUCCESS')) {
63+
yield 'OCI8 deadlock' => [self::connection(OCI8Connection::class, 'OCI8'), 60];
64+
65+
yield 'OCI8 serialization failure' => [self::connection(OCI8Connection::class, 'OCI8'), 8177];
66+
}
67+
}
68+
69+
#[DataProvider('provideRejectsNonRetryableTransactionExceptions')]
70+
public function testRejectsNonRetryableTransactionExceptions(BaseConnection $db, int|string $code): void
71+
{
72+
$exception = new DatabaseException('Non-retryable transaction failure.', $code);
73+
74+
$this->assertFalse($db->isRetryableTransactionException($exception));
75+
}
76+
77+
/**
78+
* @return iterable<string, array{BaseConnection, int|string}>
79+
*/
80+
public static function provideRejectsNonRetryableTransactionExceptions(): iterable
81+
{
82+
yield 'Base connection default' => [self::connection(MockConnection::class, 'MockDriver'), 1213];
83+
84+
yield 'MySQLi lock wait timeout' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1205];
85+
86+
yield 'MySQLi duplicate key' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1062];
87+
88+
yield 'Postgre unique violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23505'];
89+
90+
yield 'Postgre exclusion violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23P01'];
91+
92+
yield 'SQLite locked' => [self::connection(SQLite3Connection::class, 'SQLite3'), 6];
93+
94+
yield 'SQLite busy snapshot extended code' => [self::connection(SQLite3Connection::class, 'SQLite3'), 517];
95+
96+
yield 'SQLite constraint' => [self::connection(SQLite3Connection::class, 'SQLite3'), 19];
97+
98+
yield 'SQLSRV lock timeout' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HYT00/1222'];
99+
100+
yield 'SQLSRV SQLSTATE without vendor code' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001'];
101+
102+
yield 'SQLSRV unique constraint' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2627'];
103+
104+
yield 'SQLSRV unique index' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2601'];
105+
106+
if (defined('OCI_COMMIT_ON_SUCCESS')) {
107+
yield 'OCI8 resource busy' => [self::connection(OCI8Connection::class, 'OCI8'), 54];
108+
109+
yield 'OCI8 unique constraint' => [self::connection(OCI8Connection::class, 'OCI8'), 1];
110+
}
111+
}
112+
113+
public function testRejectsNonDatabaseExceptions(): void
114+
{
115+
$db = self::connection(MySQLiConnection::class, 'MySQLi');
116+
117+
$this->assertFalse($db->isRetryableTransactionException(new RuntimeException('Not a database exception.')));
118+
}
119+
120+
public function testRejectsUniqueConstraintViolationExceptions(): void
121+
{
122+
$db = self::connection(MySQLiConnection::class, 'MySQLi');
123+
124+
$this->assertFalse($db->isRetryableTransactionException(
125+
new UniqueConstraintViolationException('Duplicate key.', 1062),
126+
));
127+
}
128+
129+
/**
130+
* @param class-string<BaseConnection> $connectionClass
131+
*/
132+
private static function connection(string $connectionClass, string $driver): BaseConnection
133+
{
134+
return new $connectionClass([
135+
'DSN' => '',
136+
'hostname' => 'localhost',
137+
'username' => '',
138+
'password' => '',
139+
'database' => 'test',
140+
'DBDriver' => $driver,
141+
'DBDebug' => true,
142+
'charset' => 'utf8',
143+
'DBCollat' => 'utf8_general_ci',
144+
'swapPre' => '',
145+
'encrypt' => false,
146+
'compress' => false,
147+
'failover' => [],
148+
]);
149+
}
150+
}

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Interface Changes
4343
**NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to
4444
update your implementations to include the new methods or method changes to ensure compatibility.
4545

46-
- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, and ``transaction()`` methods.
46+
- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, ``isRetryableTransactionException()``, and ``transaction()`` methods.
4747
- **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.
4848
- **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``.
4949

@@ -201,8 +201,9 @@ Database
201201
========
202202

203203
- 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`.
204-
- Added the ``transaction()`` method to database connections to run a callback inside a transaction. See :ref:`transactions-closure`.
205204
- Added ``inTransaction()`` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See :ref:`transactions-checking-transaction-state`.
205+
- Added ``isRetryableTransactionException()`` to database connections to identify driver-specific retryable transaction failures such as deadlocks and serialization failures. See :ref:`transactions-retryable-exceptions`.
206+
- Added the ``transaction()`` method to database connections to run a callback inside a transaction. See :ref:`transactions-closure`.
206207
- 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.
207208

208209
Query Builder

user_guide_src/source/database/transactions.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ Callbacks registered with ``afterCommit()`` or ``afterRollback()`` inside the
8282
transaction callback follow the same rules as other transaction callbacks: they
8383
run only after the outermost transaction commits or rolls back.
8484

85+
.. _transactions-retryable-exceptions:
86+
87+
Classifying Retryable Transaction Failures
88+
==========================================
89+
90+
.. versionadded:: 4.8.0
91+
92+
Some database engines report transaction failures that may succeed when the
93+
entire transaction is attempted again, such as deadlocks or serialization
94+
failures. The ``isRetryableTransactionException()`` method checks whether a
95+
``DatabaseException`` represents one of these driver-specific failures so you
96+
can decide how your application should respond:
97+
98+
.. literalinclude:: transactions/015.php
99+
100+
This method is only a classifier. It does not retry the transaction
101+
automatically. If you retry, run the whole transaction again. Avoid
102+
non-transactional side effects inside transaction bodies that may be retried.
103+
For side effects such as queued jobs, emails, cache invalidation, or external
104+
API calls, register them with ``afterCommit()`` so they run only after the
105+
transaction commits.
106+
107+
When ``DBDebug`` is ``false`` and a failed query returns ``false`` instead of
108+
throwing, inspect ``getLastException()`` immediately after the failed operation
109+
and pass that exception to ``isRetryableTransactionException()``.
110+
85111
Strict Mode
86112
===========
87113

0 commit comments

Comments
 (0)