Skip to content

Commit acc0c1c

Browse files
authored
Merge pull request #895 from utopia-php/fix/start-transaction-desynced-rollback
fix(transaction): tolerate desynced cleanup rollback on startTransaction
2 parents cfba533 + 240b957 commit acc0c1c

3 files changed

Lines changed: 105 additions & 10 deletions

File tree

src/Database/Adapter/Postgres.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,18 @@ public function startTransaction(): bool
3838
{
3939
try {
4040
if ($this->inTransaction === 0) {
41-
if ($this->getPDO()->inTransaction()) {
42-
$this->getPDO()->rollBack();
43-
} else {
44-
// If no active transaction, this has no effect.
45-
$this->getPDO()->prepare('ROLLBACK')->execute();
41+
try {
42+
if ($this->getPDO()->inTransaction()) {
43+
$this->getPDO()->rollBack();
44+
} else {
45+
// If no active transaction, this has no effect.
46+
$this->getPDO()->prepare('ROLLBACK')->execute();
47+
}
48+
} catch (PDOException) {
49+
// A pooled connection can report a transaction it no longer
50+
// holds after a reconnect (e.g. Swoole PDOProxy keeps its own
51+
// counter), making this cleanup rollback throw. It is best
52+
// effort; swallow it and begin a fresh transaction below.
4653
}
4754

4855
$result = $this->getPDO()->beginTransaction();

src/Database/Adapter/SQL.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,18 @@ public function startTransaction(): bool
8080
{
8181
try {
8282
if ($this->inTransaction === 0) {
83-
if ($this->getPDO()->inTransaction()) {
84-
$this->getPDO()->rollBack();
85-
} else {
86-
// If no active transaction, this has no effect.
87-
$this->getPDO()->prepare('ROLLBACK')->execute();
83+
try {
84+
if ($this->getPDO()->inTransaction()) {
85+
$this->getPDO()->rollBack();
86+
} else {
87+
// If no active transaction, this has no effect.
88+
$this->getPDO()->prepare('ROLLBACK')->execute();
89+
}
90+
} catch (PDOException) {
91+
// A pooled connection can report a transaction it no longer
92+
// holds after a reconnect (e.g. Swoole PDOProxy keeps its own
93+
// counter), making this cleanup rollback throw. It is best
94+
// effort; swallow it and begin a fresh transaction below.
8895
}
8996

9097
$this->getPDO()->beginTransaction();

tests/unit/SQLTransactionTest.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace Tests\Unit;
4+
5+
use PDOException;
6+
use PHPUnit\Framework\TestCase;
7+
use Utopia\Database\Adapter\MySQL;
8+
use Utopia\Database\Adapter\Postgres;
9+
use Utopia\Database\Exception\Transaction as TransactionException;
10+
11+
class SQLTransactionTest extends TestCase
12+
{
13+
/**
14+
* A pooled connection (e.g. Swoole PDOProxy) can keep its own transaction
15+
* counter that survives a reconnect, so it reports an open transaction the
16+
* underlying connection no longer holds. The cleanup rollBack() then throws
17+
* "There is no active transaction". startTransaction() must swallow that and
18+
* still begin a fresh transaction instead of surfacing it as a failure.
19+
*/
20+
public function testStartTransactionRecoversFromDesyncedRollback(): void
21+
{
22+
$pdo = $this->getMockBuilder(\PDO::class)
23+
->disableOriginalConstructor()
24+
->getMock();
25+
26+
$pdo->method('inTransaction')->willReturn(true);
27+
$pdo->method('rollBack')->willThrowException(
28+
new PDOException('There is no active transaction')
29+
);
30+
$pdo->expects($this->once())
31+
->method('beginTransaction')
32+
->willReturn(true);
33+
34+
$adapter = new MySQL($pdo);
35+
36+
$this->assertTrue($adapter->startTransaction());
37+
$this->assertTrue($adapter->inTransaction());
38+
}
39+
40+
public function testStartTransactionDoesNotMaskBeginFailureAfterDesyncedRollback(): void
41+
{
42+
$pdo = $this->getMockBuilder(\PDO::class)
43+
->disableOriginalConstructor()
44+
->getMock();
45+
46+
$pdo->method('inTransaction')->willReturn(true);
47+
$pdo->method('rollBack')->willThrowException(
48+
new PDOException('There is no active transaction')
49+
);
50+
$pdo->expects($this->once())
51+
->method('beginTransaction')
52+
->willThrowException(new PDOException('Connection lost'));
53+
54+
$adapter = new MySQL($pdo);
55+
56+
$this->expectException(TransactionException::class);
57+
$this->expectExceptionMessage('Failed to start transaction: Connection lost');
58+
59+
$adapter->startTransaction();
60+
}
61+
62+
public function testPostgresStartTransactionRecoversFromDesyncedRollback(): void
63+
{
64+
$pdo = $this->getMockBuilder(\PDO::class)
65+
->disableOriginalConstructor()
66+
->getMock();
67+
68+
$pdo->method('inTransaction')->willReturn(true);
69+
$pdo->method('rollBack')->willThrowException(
70+
new PDOException('There is no active transaction')
71+
);
72+
$pdo->expects($this->once())
73+
->method('beginTransaction')
74+
->willReturn(true);
75+
76+
$adapter = new Postgres($pdo);
77+
78+
$this->assertTrue($adapter->startTransaction());
79+
$this->assertTrue($adapter->inTransaction());
80+
}
81+
}

0 commit comments

Comments
 (0)