Skip to content

Commit d64b06a

Browse files
fix: end the transaction when transCommit() returns false after 1213
The restart BEGIN ran outside the query try block, after the restart flag was cleared. If the connection dropped after errno 1213, query(), ping() and reconnect() threw a raw mysqli_sql_exception, even with DBDebug false, and the next write skipped BEGIN. BEGIN now runs inside the try, and the restart stays pending until it succeeds. transCommit() returned false and left transDepth at 1 with the restarted server transaction open. A caller that retried without transRollback() nested inside it, got true from transCommit(), and nothing was committed. transCommit() now rolls that transaction back before returning false. The guide and changelog now limit the fix to query(), simpleQuery() and the Query Builder. Prepared queries are not covered. The deadlock test records the probe table before recreating it, so teardown drops it. Refs #10649 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3745b0a commit d64b06a

5 files changed

Lines changed: 105 additions & 20 deletions

File tree

‎system/Database/BaseConnection.php‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,12 @@ public function transCommit(): bool
10861086

10871087
// InnoDB already removed the victim transaction. mysqli::commit()
10881088
// returns true anyway, which would publish later writes and report success.
1089+
// Roll back the restarted server transaction so a false return ends the
1090+
// transaction, as transComplete() does. Otherwise a caller that retries
1091+
// without transRollback() nests inside it and nothing commits.
10891092
if ($this->transServerRolledBack) {
1093+
$this->transRollback();
1094+
10901095
return false;
10911096
}
10921097

@@ -1154,17 +1159,19 @@ protected function markServerRolledBackTransaction(): void
11541159
}
11551160

11561161
/**
1157-
* True once, when the next statement must BEGIN before it runs.
1162+
* True while the next statement must BEGIN before it runs.
11581163
*/
1159-
protected function consumeServerTransactionRestart(): bool
1164+
protected function serverTransactionRestartPending(): bool
11601165
{
1161-
if (! $this->transNeedsServerRestart || $this->transDepth < 1) {
1162-
return false;
1163-
}
1166+
return $this->transNeedsServerRestart && $this->transDepth > 0;
1167+
}
11641168

1169+
/**
1170+
* BEGIN succeeded. Clear the restart only now, so a failed BEGIN keeps it pending.
1171+
*/
1172+
protected function serverTransactionRestarted(): void
1173+
{
11651174
$this->transNeedsServerRestart = false;
1166-
1167-
return true;
11681175
}
11691176

11701177
private function clearServerRolledBackTransaction(): void

‎system/Database/MySQLi/Connection.php‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,6 @@ public function getVersion(): string
283283
*/
284284
protected function execute(string $sql)
285285
{
286-
// Errno 1213 ends the server transaction. Begin again before this
287-
// statement so it does not autocommit. Errno 1205 is not handled:
288-
// innodb_rollback_on_timeout decides whether InnoDB rolled that
289-
// transaction back.
290-
if ($this->connID instanceof mysqli && $this->consumeServerTransactionRestart()) {
291-
$this->connID->begin_transaction();
292-
}
293-
294286
while ($this->connID->more_results()) {
295287
$this->connID->next_result();
296288
if ($res = $this->connID->store_result()) {
@@ -299,6 +291,16 @@ protected function execute(string $sql)
299291
}
300292

301293
try {
294+
// Errno 1213 ends the server transaction. Begin again before this
295+
// statement so it does not autocommit. A failed BEGIN goes through
296+
// the normal error path below, and the restart stays pending.
297+
// Errno 1205 is not handled: innodb_rollback_on_timeout decides
298+
// whether InnoDB rolled that transaction back.
299+
if ($this->connID instanceof mysqli && $this->serverTransactionRestartPending()) {
300+
$this->connID->begin_transaction();
301+
$this->serverTransactionRestarted();
302+
}
303+
302304
return $this->connID->query($this->prepQuery($sql), $this->resultMode);
303305
} catch (mysqli_sql_exception $e) {
304306
log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [

‎tests/system/Database/Live/MySQLi/DeadlockTransactionTest.php‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,72 @@ public function testFollowingWriteDoesNotAutocommitAndTransCompleteRollsItBack()
6969
$this->assertDeadlockVictimDoesNotPublishTheNextWrite(true);
7070
}
7171

72+
public function testFalseTransCommitEndsTheTransactionSoARetryCommits(): void
73+
{
74+
$table = $this->db->escapeIdentifier($this->db->prefixTable('deadlock_probe'));
75+
$this->probeTable = $table;
76+
$this->forceDeadlock($table);
77+
78+
$this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (102, 'after')");
79+
80+
$firstCommit = $this->db->transCommit();
81+
$depthAfter = $this->db->transDepth;
82+
83+
// A caller that retries after the false commit must get a real transaction,
84+
// not a nested one inside the restarted server transaction.
85+
$this->db->transBegin();
86+
$this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (103, 'retry')");
87+
$retryCommit = $this->db->transCommit();
88+
89+
$this->assertSame(
90+
['first_commit' => false, 'depth_after' => 0, 'retry_commit' => true, 'visible' => [103]],
91+
[
92+
'first_commit' => $firstCommit,
93+
'depth_after' => $depthAfter,
94+
'retry_commit' => $retryCommit,
95+
'visible' => $this->committedIds($table),
96+
],
97+
);
98+
}
99+
100+
public function testLostConnectionAfterDeadlockDoesNotEscapeAsDriverException(): void
101+
{
102+
$table = $this->db->escapeIdentifier($this->db->prefixTable('deadlock_probe'));
103+
$this->probeTable = $table;
104+
$this->forceDeadlock($table);
105+
$this->killConnection($this->db);
106+
107+
// The restart BEGIN fails on the dead socket. That must surface through the
108+
// normal query error path, not as a raw mysqli_sql_exception.
109+
$insert = $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (102, 'after')");
110+
$this->db->reconnect();
111+
112+
$this->assertSame(
113+
['insert' => false, 'complete' => false],
114+
['insert' => $insert, 'complete' => $this->db->transComplete()],
115+
);
116+
}
117+
118+
private function killConnection(BaseConnection $db): void
119+
{
120+
$threadId = $db->getConnection()->thread_id;
121+
$observer = Database::connect($this->DBGroup, false);
122+
$observer->initialize();
123+
$observer->query('KILL ' . $threadId);
124+
125+
for ($i = 0; $i < 50; $i++) {
126+
$alive = $observer->query('SELECT COUNT(*) AS n FROM information_schema.processlist WHERE id = ' . $threadId)->getRow()->n;
127+
128+
if ((int) $alive === 0) {
129+
break;
130+
}
131+
132+
usleep(20000);
133+
}
134+
135+
$observer->close();
136+
}
137+
72138
private function assertDeadlockVictimDoesNotPublishTheNextWrite(bool $useComplete): void
73139
{
74140
$table = $this->db->escapeIdentifier($this->db->prefixTable('deadlock_probe'));
@@ -140,6 +206,10 @@ private function forceDeadlock(string $table): void
140206

141207
private function resetProbe(string $table): void
142208
{
209+
// releaseDeadlock() clears this marker after it drops the table.
210+
// Record the name again before recreating it, including when setup fails.
211+
$this->probeTable = $table;
212+
143213
if ($this->db->transDepth > 0) {
144214
$this->db->transRollback();
145215
}
@@ -162,7 +232,7 @@ private function committedIds(string $table): array
162232
{
163233
$observer = Database::connect($this->DBGroup, false);
164234
$observer->initialize();
165-
$rows = $observer->query('SELECT id FROM ' . $table . ' WHERE id IN (101, 102) ORDER BY id')->getResultArray();
235+
$rows = $observer->query('SELECT id FROM ' . $table . ' WHERE id > 100 ORDER BY id')->getResultArray();
166236
$observer->close();
167237

168238
$ids = [];

‎user_guide_src/source/changelogs/v4.7.6.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Bugs Fixed
3939
- **CLI:** Fixed a bug where a prompt asked again forever when its input was piped or redirected and ran out before a valid answer: ``CLI::prompt()`` and ``CLI::promptByKey()`` whose default fails the validation rules, and ``CLI::promptByMultipleKeys()`` whose options have string keys. They now throw a ``CLIException``. The new ``InputOutput::isAtEnd()`` tells that end of the input from an empty line. Pressing Ctrl+D at a terminal still asks again.
4040
- **Commands:** Fixed a bug where generator commands did not sort the imports of a template with CRLF line endings.
4141
- **Database:** Fixed ``reconnect()`` so a replaced socket does not keep the transaction that died with the old connection. ``transDepth`` is cleared and the transaction status is failed before the new connection is opened, so a failed reconnect cannot still report success. OCI8 commit mode returns to autocommit. ``transComplete()`` does not report success. This applies when ``ping()`` reports the connection lost; PostgreSQL's ``pg_ping()`` reconnects a broken connection itself, so it is not covered.
42-
- **Database:** Fixed MySQLi errno 1213 (``ER_LOCK_DEADLOCK``) so a following write on the victim connection does not autocommit, and ``transCommit()`` does not return true after InnoDB has rolled the victim transaction back. Errno 1205 is unchanged. See `#10649 <https://github.com/codeigniter4/CodeIgniter4/issues/10649>`_.
42+
- **Database:** Fixed MySQLi errno 1213 (``ER_LOCK_DEADLOCK``) so a following write through ``query()``, ``simpleQuery()`` or the Query Builder on the victim connection does not autocommit, and ``transCommit()`` rolls back and returns false after InnoDB has rolled the victim transaction back. Prepared queries and errno 1205 are unchanged. See `#10649 <https://github.com/codeigniter4/CodeIgniter4/issues/10649>`_.
4343
- **Format:** Fixed ``XMLFormatter`` so that an array key that becomes empty after sanitization no longer triggers a ``ValueError``; the fallback tag name ``item`` is used instead. Values that cannot be converted to a string, such as objects not implementing ``Stringable``, now throw a ``FormatException`` instead of a fatal ``Error``.
4444
- **Helpers:** Fixed a regression in v4.7.5 where ``set_value()`` threw a ``TypeError`` when the default value was not a string or an array, such as an integer, a float, ``null``, or a ``Stringable`` object. Such values are now converted to a string.
4545
- **HTTP:** Fixed a bug in ``ContentSecurityPolicy`` where a numeric source such as ``'0'`` threw a ``TypeError``, and a space-separated directive string in ``Config\\ContentSecurityPolicy`` was emitted as one unquoted source.

‎user_guide_src/source/database/transactions.rst‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,15 @@ MySQL Deadlocks
149149
===============
150150

151151
InnoDB error 1213 rolls the victim transaction back. CodeIgniter keeps that
152-
transaction open. The next statement on the same connection starts a new
153-
server transaction, so the write does not autocommit, and ``transCommit()``
154-
returns false. ``transComplete()`` rolls the new server transaction back.
152+
transaction open. The next statement run through ``query()``, ``simpleQuery()``
153+
or the Query Builder on the same connection starts a new server transaction,
154+
so the write does not autocommit. ``transCommit()`` rolls that server
155+
transaction back and returns false, so the transaction has ended when it
156+
returns. ``transComplete()`` also rolls it back.
157+
158+
Prepared queries are not covered. A deadlock raised by a prepared query is not
159+
detected, and a prepared query run after a deadlock does not start a new server
160+
transaction.
155161

156162
Error 1205 is not handled this way. ``innodb_rollback_on_timeout`` decides
157163
whether InnoDB rolled that transaction back, and the message is the same

0 commit comments

Comments
 (0)