diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index ef0598341..6250ef399 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -436,12 +436,13 @@ public function withTransaction(callable $callback): mixed try { $this->rollbackTransaction(); } catch (\Throwable $rollback) { + $this->inTransaction = 0; + if ($attempts < $retries) { \usleep($sleep * ($attempts + 1)); continue; } - $this->inTransaction = 0; throw $rollback; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index d81cdec0b..76d598537 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -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(); diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index e56678f8e..bc470f97d 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -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. } $this->getPDO()->beginTransaction(); diff --git a/tests/unit/TransactionTest.php b/tests/unit/TransactionTest.php new file mode 100644 index 000000000..19a07dbdf --- /dev/null +++ b/tests/unit/TransactionTest.php @@ -0,0 +1,92 @@ +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); + } +}