Skip to content

Commit 466d0f7

Browse files
authored
feat: add transaction lifecycle callbacks (#10144)
* feat: add transaction lifecycle callbacks - Add afterCommit() and afterRollback() callbacks to database connections - Run callbacks only after the outermost transaction commits or rolls back - Discard callbacks registered for the opposite transaction outcome - Document callback exception behavior and automatic rollback handling - Add live database coverage for callback ordering, nesting, and failures Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * feat(database): refine transaction callback API Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs(database): document transaction callback interface break Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs(database): sort interface change entry Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * test(database): address transaction callback review notes Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * test(database): cover transaction callback retry behavior - Add commit callback coverage when driver commit fails once - Add rollback callback coverage when driver rollback fails once - Verify callbacks run after retry and are cleared afterward Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * fix(database): harden worker transaction cleanup - Catch rollback callback exceptions during worker cleanup - Log cleanup callback failures as critical - Add regression coverage for worker-mode rollback cleanup Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 5140773 commit 466d0f7

10 files changed

Lines changed: 575 additions & 8 deletions

File tree

system/Database/BaseConnection.php

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,20 @@ abstract class BaseConnection implements ConnectionInterface
358358
*/
359359
protected bool $transException = false;
360360

361+
/**
362+
* Callbacks to run after the outermost transaction commits.
363+
*
364+
* @var list<callable(): void>
365+
*/
366+
protected array $transCommitCallbacks = [];
367+
368+
/**
369+
* Callbacks to run after the outermost transaction rolls back.
370+
*
371+
* @var list<callable(): void>
372+
*/
373+
protected array $transRollbackCallbacks = [];
374+
361375
/**
362376
* Array of table aliases.
363377
*
@@ -985,13 +999,15 @@ public function transComplete(): bool
985999

9861000
// The query() function will set this flag to FALSE in the event that a query failed
9871001
if ($this->transStatus === false || $this->transFailure === true) {
988-
$this->transRollback();
989-
990-
// If we are NOT running in strict mode, we will reset
991-
// the _trans_status flag so that subsequent groups of
992-
// transactions will be permitted.
993-
if ($this->transStrict === false) {
994-
$this->transStatus = true;
1002+
try {
1003+
$this->transRollback();
1004+
} finally {
1005+
// If we are NOT running in strict mode, we will reset
1006+
// the _trans_status flag so that subsequent groups of
1007+
// transactions will be permitted.
1008+
if ($this->transStrict === false) {
1009+
$this->transStatus = true;
1010+
}
9951011
}
9961012

9971013
return false;
@@ -1008,6 +1024,48 @@ public function transStatus(): bool
10081024
return $this->transStatus;
10091025
}
10101026

1027+
/**
1028+
* Register a callback to run after the outermost transaction commits.
1029+
*
1030+
* If no transaction is active, the callback runs immediately.
1031+
*
1032+
* @param callable(): void $callback
1033+
*
1034+
* @return $this
1035+
*/
1036+
public function afterCommit(callable $callback): static
1037+
{
1038+
if ($this->transDepth === 0) {
1039+
$callback();
1040+
1041+
return $this;
1042+
}
1043+
1044+
$this->transCommitCallbacks[] = $callback;
1045+
1046+
return $this;
1047+
}
1048+
1049+
/**
1050+
* Register a callback to run after the outermost transaction rolls back.
1051+
*
1052+
* If no transaction is active, the callback is not run.
1053+
*
1054+
* @param callable(): void $callback
1055+
*
1056+
* @return $this
1057+
*/
1058+
public function afterRollback(callable $callback): static
1059+
{
1060+
if ($this->transDepth === 0) {
1061+
return $this;
1062+
}
1063+
1064+
$this->transRollbackCallbacks[] = $callback;
1065+
1066+
return $this;
1067+
}
1068+
10111069
/**
10121070
* Begin Transaction
10131071
*/
@@ -1055,6 +1113,11 @@ public function transCommit(): bool
10551113
if ($this->transDepth > 1 || $this->_transCommit()) {
10561114
$this->transDepth--;
10571115

1116+
if ($this->transDepth === 0) {
1117+
$this->transRollbackCallbacks = [];
1118+
$this->runTransCommitCallbacks();
1119+
}
1120+
10581121
return true;
10591122
}
10601123

@@ -1074,6 +1137,11 @@ public function transRollback(): bool
10741137
if ($this->transDepth > 1 || $this->_transRollback()) {
10751138
$this->transDepth--;
10761139

1140+
if ($this->transDepth === 0) {
1141+
$this->transCommitCallbacks = [];
1142+
$this->runTransRollbackCallbacks();
1143+
}
1144+
10771145
return true;
10781146
}
10791147

@@ -1102,6 +1170,32 @@ public function handleTransStatus(): void
11021170
}
11031171
}
11041172

1173+
/**
1174+
* Run and clear callbacks registered for a successful transaction commit.
1175+
*/
1176+
protected function runTransCommitCallbacks(): void
1177+
{
1178+
$callbacks = $this->transCommitCallbacks;
1179+
$this->transCommitCallbacks = [];
1180+
1181+
foreach ($callbacks as $callback) {
1182+
$callback();
1183+
}
1184+
}
1185+
1186+
/**
1187+
* Run and clear callbacks registered for a transaction rollback.
1188+
*/
1189+
protected function runTransRollbackCallbacks(): void
1190+
{
1191+
$callbacks = $this->transRollbackCallbacks;
1192+
$this->transRollbackCallbacks = [];
1193+
1194+
foreach ($callbacks as $callback) {
1195+
$callback();
1196+
}
1197+
}
1198+
11051199
/**
11061200
* Begin Transaction
11071201
*/

system/Database/Config.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use CodeIgniter\Config\BaseConfig;
1717
use CodeIgniter\Exceptions\InvalidArgumentException;
1818
use Config\Database as DbConfig;
19+
use Throwable;
1920

2021
/**
2122
* @see \CodeIgniter\Database\ConfigTest
@@ -184,7 +185,12 @@ public static function cleanupForWorkerMode(): void
184185
log_message('error', "Uncommitted transaction detected in database group '{$group}'. Transactions must be completed before request ends.");
185186

186187
while ($connection->transDepth > 0) {
187-
$connection->transRollback();
188+
try {
189+
$connection->transRollback();
190+
} catch (Throwable $e) {
191+
log_message('critical', $e->getMessage());
192+
break;
193+
}
188194
}
189195
}
190196

system/Database/ConnectionInterface.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,24 @@ public function query(string $sql, $binds = null);
115115
*/
116116
public function simpleQuery(string $sql);
117117

118+
/**
119+
* Register a callback to run after the outermost transaction commits.
120+
*
121+
* @param callable(): void $callback
122+
*
123+
* @return $this
124+
*/
125+
public function afterCommit(callable $callback): static;
126+
127+
/**
128+
* Register a callback to run after the outermost transaction rolls back.
129+
*
130+
* @param callable(): void $callback
131+
*
132+
* @return $this
133+
*/
134+
public function afterRollback(callable $callback): static;
135+
118136
/**
119137
* Returns an instance of the query builder for this connection.
120138
*

tests/system/Database/BaseConnectionTest.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,72 @@ public function testGetSessionTimezoneWithoutTimezoneKey(): void
558558
$this->assertNull($result);
559559
}
560560

561+
public function testAfterCommitCallbacksRemainQueuedWhenDriverCommitFails(): void
562+
{
563+
$callbacks = [];
564+
565+
$db = new class ($this->options) extends MockConnection {
566+
public int $commitAttempts = 0;
567+
568+
protected function _transCommit(): bool
569+
{
570+
$this->commitAttempts++;
571+
572+
return $this->commitAttempts > 1;
573+
}
574+
};
575+
576+
$this->assertTrue($db->transBegin());
577+
$db->afterCommit(static function () use (&$callbacks): void {
578+
$callbacks[] = 'committed';
579+
});
580+
581+
$this->assertFalse($db->transCommit());
582+
$this->assertSame([], $callbacks);
583+
$this->assertSame(1, $db->transDepth);
584+
585+
$this->assertTrue($db->transCommit());
586+
$this->assertSame(['committed'], $callbacks);
587+
$this->assertSame(0, $db->transDepth);
588+
589+
$this->assertTrue($db->transBegin());
590+
$this->assertTrue($db->transCommit());
591+
$this->assertSame(['committed'], $callbacks);
592+
}
593+
594+
public function testAfterRollbackCallbacksRemainQueuedWhenDriverRollbackFails(): void
595+
{
596+
$callbacks = [];
597+
598+
$db = new class ($this->options) extends MockConnection {
599+
public int $rollbackAttempts = 0;
600+
601+
protected function _transRollback(): bool
602+
{
603+
$this->rollbackAttempts++;
604+
605+
return $this->rollbackAttempts > 1;
606+
}
607+
};
608+
609+
$this->assertTrue($db->transBegin());
610+
$db->afterRollback(static function () use (&$callbacks): void {
611+
$callbacks[] = 'rolled back';
612+
});
613+
614+
$this->assertFalse($db->transRollback());
615+
$this->assertSame([], $callbacks);
616+
$this->assertSame(1, $db->transDepth);
617+
618+
$this->assertTrue($db->transRollback());
619+
$this->assertSame(['rolled back'], $callbacks);
620+
$this->assertSame(0, $db->transDepth);
621+
622+
$this->assertTrue($db->transBegin());
623+
$this->assertTrue($db->transRollback());
624+
$this->assertSame(['rolled back'], $callbacks);
625+
}
626+
561627
public function testCallFunctionDoesNotDoublePrefixAlreadyPrefixedName(): void
562628
{
563629
$db = new class ($this->options) extends MockConnection {

0 commit comments

Comments
 (0)