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
3 changes: 2 additions & 1 deletion src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -436,12 +436,13 @@ public function withTransaction(callable $callback): mixed
try {
$this->rollbackTransaction();
} catch (\Throwable $rollback) {
$this->inTransaction = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Nested retry breaks state

This reset also runs when the failing rollback belongs to an inner savepoint. In a nested withTransaction(), a transient error in the inner callback can make the savepoint rollback fail, reset inTransaction to 0, and then retry the inner callback as a new top-level transaction. That retry can issue the top-level cleanup rollback and abort the outer transaction, while the outer callback continues and later commits or no-ops with invalid state. This can commit inner work independently and lose the outer transaction's atomicity.


if ($attempts < $retries) {
\usleep($sleep * ($attempts + 1));
continue;
Comment on lines +439 to 443

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unsafe rollback retry

This path resets inTransaction and retries the callback after any rollback exception. For adapters that inherit this base method, a rollback failure can mean the rollback only partially completed rather than that the transaction counter is stale. For example, Memory::rollbackTransaction() replays inverse callbacks; if one inverse throws after changing state, this branch clears the counter and runs the user callback again on dirty state, which can duplicate side effects or leave persisted data inconsistent. Only retry when the rollback failure is the known stale-transaction case, otherwise surface the rollback failure.

}

$this->inTransaction = 0;
throw $rollback;
}

Expand Down
15 changes: 10 additions & 5 deletions src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,16 @@ public function startTransaction(): bool
{
try {
if ($this->inTransaction === 0) {
if ($this->getPDO()->inTransaction()) {
$this->getPDO()->rollBack();
} else {
// If no active transaction, this has no effect.
$this->getPDO()->prepare('ROLLBACK')->execute();
try {
if ($this->getPDO()->inTransaction()) {
$this->getPDO()->rollBack();
} else {
$this->getPDO()->prepare('ROLLBACK')->execute();
}
} catch (PDOException) {
// Defensive cleanup — if there is nothing to roll back
// (stale persistent connection, implicit commit, etc.)
// we can safely ignore the failure and proceed.
}

$result = $this->getPDO()->beginTransaction();
Expand Down
15 changes: 10 additions & 5 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,16 @@ public function startTransaction(): bool
{
try {
if ($this->inTransaction === 0) {
if ($this->getPDO()->inTransaction()) {
$this->getPDO()->rollBack();
} else {
// If no active transaction, this has no effect.
$this->getPDO()->prepare('ROLLBACK')->execute();
try {
if ($this->getPDO()->inTransaction()) {
$this->getPDO()->rollBack();
} else {
$this->getPDO()->prepare('ROLLBACK')->execute();
}
} catch (PDOException) {
// Defensive cleanup — if there is nothing to roll back
// (stale persistent connection, implicit commit, etc.)
// we can safely ignore the failure and proceed.
}
Comment on lines +83 to 93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unexpected rollback errors hidden

The defensive rollback handling in both src/Database/Adapter/SQL.php and src/Database/Adapter/Postgres.php suppresses every PDOException raised while cleaning up before beginTransaction(). That is safe for the targeted stale “no active transaction” case, but it also hides real cleanup failures such as connection, protocol, or server errors. When that happens, beginTransaction() can run after a failed cleanup attempt and callers can execute on an unknown transaction state with the original rollback error discarded. Please only ignore the specific benign stale-transaction error and rethrow other PDOExceptions.

Artifacts

Repro: focused PHP harness using the real SQL adapter path with a fake PDO call trace

  • Contains supporting evidence from the run (text/x-php; charset=utf-8).

Repro: execution output showing rollback error swallowed and beginTransaction called

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex


$this->getPDO()->beginTransaction();
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/TransactionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace Tests\Unit;

use PDO;
use PDOException;
use PDOStatement;
use PHPUnit\Framework\TestCase;
use Utopia\Database\Adapter\MariaDB;
use Utopia\Database\Exception\Transaction as TransactionException;

class TransactionTest extends TestCase
{
/**
* Regression: when PDO::inTransaction() returns true but the server-side
* transaction is already gone (stale persistent connection, implicit
* commit, etc.), the defensive rollBack() in startTransaction() must not
* propagate "There is no active transaction" — it should silently recover
* and begin a fresh transaction.
*/
public function testStartTransactionHandlesStaleInTransactionFlag(): void
{
$pdoMock = $this->createMock(PDO::class);

// PDO thinks a transaction is active, but rollBack() disagrees
$pdoMock->method('inTransaction')
->willReturn(true);

$pdoMock->method('rollBack')
->willThrowException(new PDOException('There is no active transaction'));

$pdoMock->expects($this->once())
->method('beginTransaction')
->willReturn(true);

$adapter = new MariaDB($pdoMock);

$result = $adapter->startTransaction();

$this->assertTrue($result);
}

/**
* Regression: when rollbackTransaction() fails during a withTransaction()
* retry, the inTransaction counter must be reset to 0 before the next
* attempt so startTransaction() takes the fresh-transaction path.
*/
public function testWithTransactionResetsCounterOnRollbackFailureDuringRetry(): void
{
$pdoMock = $this->createMock(PDO::class);
$stmtMock = $this->createMock(PDOStatement::class);
$stmtMock->method('execute')->willReturn(true);

$callCount = 0;

// First call: beginTransaction succeeds, then callback fails,
// then rollBack also fails (simulating connection issue).
// Second call: fresh beginTransaction succeeds, callback succeeds.
$pdoMock->method('inTransaction')
->willReturnCallback(function () use (&$callCount) {
// After the first failed rollback, PDO no longer thinks
// it's in a transaction.
return false;
});

$pdoMock->method('prepare')
->willReturn($stmtMock);

$pdoMock->method('beginTransaction')
->willReturn(true);

$pdoMock->method('commit')
->willReturn(true);

// rollBack fails the first time (simulating stale transaction)
$pdoMock->method('rollBack')
->willThrowException(new PDOException('There is no active transaction'));

$adapter = new MariaDB($pdoMock);

$result = $adapter->withTransaction(function () use (&$callCount) {
$callCount++;
if ($callCount === 1) {
throw new \RuntimeException('Transient error');
}
return 'success';
});

$this->assertSame('success', $result);
$this->assertSame(2, $callCount);
}
}