From 6aac2eb20d91435f15e34fc962107d4273f0d4f5 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 16:25:24 +0000 Subject: [PATCH 1/6] Remove redundant inTransaction reset after successful rollback --- src/Database/Adapter.php | 2 - tests/e2e/Adapter/Scopes/GeneralTests.php | 148 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 695ef1ba7..38c89f665 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -430,7 +430,6 @@ public function withTransaction(callable $callback): mixed $action instanceof ConflictException || $action instanceof LimitException ) { - $this->inTransaction = 0; throw $action; } @@ -439,7 +438,6 @@ public function withTransaction(callable $callback): mixed continue; } - $this->inTransaction = 0; throw $action; } } diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index d2f18772c..5fbd5bf5e 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -845,6 +845,154 @@ public function testTransactionAtomicity(): void $database->deleteCollection('transactionAtomicity'); } + /** + * Test that withTransaction correctly resets inTransaction state + * when a known exception (DuplicateException) is thrown after successful rollback. + */ + public function testTransactionStateAfterKnownException(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection('txKnownException'); + $database->createAttribute('txKnownException', 'title', Database::VAR_STRING, 128, true); + + $database->createDocument('txKnownException', new Document([ + '$id' => 'existing_doc', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => 'Original', + ])); + + // Trigger a DuplicateException inside withTransaction by inserting a duplicate ID + try { + $database->withTransaction(function () use ($database) { + $database->createDocument('txKnownException', new Document([ + '$id' => 'existing_doc', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => 'Duplicate', + ])); + }); + $this->fail('Expected DuplicateException was not thrown'); + } catch (DuplicateException $e) { + // Expected + } + + // inTransaction must be false after the exception + $this->assertFalse( + $database->getAdapter()->inTransaction(), + 'Adapter should not be in transaction after DuplicateException' + ); + + // Database should still be functional + $doc = $database->getDocument('txKnownException', 'existing_doc'); + $this->assertEquals('Original', $doc->getAttribute('title')); + + $database->deleteCollection('txKnownException'); + } + + /** + * Test that withTransaction correctly resets inTransaction state + * when retries are exhausted for a generic exception. + */ + public function testTransactionStateAfterRetriesExhausted(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $attempts = 0; + + try { + $database->withTransaction(function () use (&$attempts) { + $attempts++; + throw new \RuntimeException('Persistent failure'); + }); + $this->fail('Expected RuntimeException was not thrown'); + } catch (\RuntimeException $e) { + $this->assertEquals('Persistent failure', $e->getMessage()); + } + + // Should have attempted 3 times (initial + 2 retries) + $this->assertEquals(3, $attempts, 'Should have exhausted all retry attempts'); + + // inTransaction must be false after retries exhausted + $this->assertFalse( + $database->getAdapter()->inTransaction(), + 'Adapter should not be in transaction after retries exhausted' + ); + } + + /** + * Test that nested withTransaction calls maintain correct inTransaction state + * when the inner transaction throws a known exception. + */ + public function testNestedTransactionState(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection('txNested'); + $database->createAttribute('txNested', 'title', Database::VAR_STRING, 128, true); + + $database->createDocument('txNested', new Document([ + '$id' => 'nested_existing', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => 'Original', + ])); + + // Outer transaction should succeed even if inner transaction throws + $result = $database->withTransaction(function () use ($database) { + $database->createDocument('txNested', new Document([ + '$id' => 'outer_doc', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => 'Outer', + ])); + + // Inner transaction throws a DuplicateException + try { + $database->withTransaction(function () use ($database) { + $database->createDocument('txNested', new Document([ + '$id' => 'nested_existing', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => 'Duplicate', + ])); + }); + } catch (DuplicateException $e) { + // Caught and handled — outer transaction should continue + } + + return true; + }); + + $this->assertTrue($result); + + // inTransaction must be false after everything completes + $this->assertFalse( + $database->getAdapter()->inTransaction(), + 'Adapter should not be in transaction after nested transactions complete' + ); + + // Outer document should have been committed + $outerDoc = $database->getDocument('txNested', 'outer_doc'); + $this->assertFalse($outerDoc->isEmpty(), 'Outer transaction document should exist'); + $this->assertEquals('Outer', $outerDoc->getAttribute('title')); + + // Original document should be unchanged + $existingDoc = $database->getDocument('txNested', 'nested_existing'); + $this->assertEquals('Original', $existingDoc->getAttribute('title')); + + $database->deleteCollection('txNested'); + } + /** * Wait for Redis to be ready with a readiness probe */ From d2593e272884ae636f400b5fd989ce36b7abf14f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 16:29:50 +0000 Subject: [PATCH 2/6] Remove unreachable fail() call flagged by PHPStan --- tests/e2e/Adapter/Scopes/GeneralTests.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 5fbd5bf5e..4e3bc6bf7 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -910,7 +910,6 @@ public function testTransactionStateAfterRetriesExhausted(): void $attempts++; throw new \RuntimeException('Persistent failure'); }); - $this->fail('Expected RuntimeException was not thrown'); } catch (\RuntimeException $e) { $this->assertEquals('Persistent failure', $e->getMessage()); } From 029c4989814764b9f71820dd8531db8a62ba3650 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 16:35:09 +0000 Subject: [PATCH 3/6] Fix Mongo withTransaction nested call state corruption --- src/Database/Adapter/Mongo.php | 9 +++++++-- tests/e2e/Adapter/Scopes/GeneralTests.php | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 79ca994e8..fdf834908 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -103,8 +103,13 @@ public function withTransaction(callable $callback): mixed { // If the database is not a replica set, we can't use transactions if (!$this->client->isReplicaSet()) { - $result = $callback(); - return $result; + return $callback(); + } + + // MongoDB doesn't support nested transactions/savepoints. + // If already in a transaction, just run the callback directly. + if ($this->inTransaction > 0) { + return $callback(); } try { diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 4e3bc6bf7..1d8d1223d 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -7,6 +7,7 @@ use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\CLI\Console; +use Utopia\Database\Adapter\Mongo; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -927,12 +928,20 @@ public function testTransactionStateAfterRetriesExhausted(): void /** * Test that nested withTransaction calls maintain correct inTransaction state * when the inner transaction throws a known exception. + * + * MongoDB does not support nested transactions or savepoints, so a duplicate + * key error inside an inner transaction aborts the entire transaction. */ public function testNestedTransactionState(): void { /** @var Database $database */ $database = $this->getDatabase(); + if ($database->getAdapter() instanceof Mongo) { + $this->expectNotToPerformAssertions(); + return; + } + $database->createCollection('txNested'); $database->createAttribute('txNested', 'title', Database::VAR_STRING, 128, true); From fe8f2fca7d263dbf76d14aa8c3833276ff862873 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 16:44:01 +0000 Subject: [PATCH 4/6] Reset inTransaction on failed rollback in Postgres adapter --- src/Database/Adapter/Postgres.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 5ab3e76ba..2af11aea3 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -47,6 +47,7 @@ public function startTransaction(): bool $result = $this->getPDO()->beginTransaction(); } else { + $this->getPDO()->exec('SAVEPOINT transaction' . $this->inTransaction); $result = true; } } catch (PDOException $e) { @@ -72,9 +73,16 @@ public function rollbackTransaction(): bool } try { + if ($this->inTransaction > 1) { + $this->getPDO()->exec('ROLLBACK TO transaction' . ($this->inTransaction - 1)); + $this->inTransaction--; + return true; + } + $result = $this->getPDO()->rollBack(); $this->inTransaction = 0; } catch (PDOException $e) { + $this->inTransaction = 0; throw new DatabaseException('Failed to rollback transaction: ' . $e->getMessage(), $e->getCode(), $e); } From b2cc352f56323cbb8c9deb87368ce4ed30d2c7e9 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 16:57:57 +0000 Subject: [PATCH 5/6] Skip retry exhaustion test for MongoDB adapter --- tests/e2e/Adapter/Scopes/GeneralTests.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 1d8d1223d..bdde17dd1 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -898,12 +898,20 @@ public function testTransactionStateAfterKnownException(): void /** * Test that withTransaction correctly resets inTransaction state * when retries are exhausted for a generic exception. + * + * MongoDB's withTransaction has no retry logic, so this test + * only applies to SQL-based adapters. */ public function testTransactionStateAfterRetriesExhausted(): void { /** @var Database $database */ $database = $this->getDatabase(); + if ($database->getAdapter() instanceof Mongo) { + $this->expectNotToPerformAssertions(); + return; + } + $attempts = 0; try { From 143e3bb3f6e0c8ae00eb2527cf713a1cf317b21c Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 18 Feb 2026 06:58:30 +0000 Subject: [PATCH 6/6] Add adapter capability methods for transaction support --- src/Database/Adapter.php | 14 ++++++++++++++ src/Database/Adapter/Mongo.php | 10 ++++++++++ src/Database/Adapter/Pool.php | 10 ++++++++++ src/Database/Adapter/SQL.php | 10 ++++++++++ tests/e2e/Adapter/Scopes/GeneralTests.php | 5 ++--- 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 38c89f665..7a629c695 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1529,4 +1529,18 @@ public function getSupportForTTLIndexes(): bool { return false; } + + /** + * Does the adapter support transaction retries? + * + * @return bool + */ + abstract public function getSupportForTransactionRetries(): bool; + + /** + * Does the adapter support nested transactions? + * + * @return bool + */ + abstract public function getSupportForNestedTransactions(): bool; } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index fdf834908..52acc9541 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3692,6 +3692,16 @@ public function getSupportForTTLIndexes(): bool return true; } + public function getSupportForTransactionRetries(): bool + { + return false; + } + + public function getSupportForNestedTransactions(): bool + { + return false; + } + protected function isExtendedISODatetime(string $val): bool { /** diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 63d3d9a0b..3128d97ed 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -703,4 +703,14 @@ public function getSupportForTTLIndexes(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } + + public function getSupportForTransactionRetries(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForNestedTransactions(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9b63bc865..702c1ea2a 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -3577,4 +3577,14 @@ public function getLockType(): string return ''; } + + public function getSupportForTransactionRetries(): bool + { + return true; + } + + public function getSupportForNestedTransactions(): bool + { + return true; + } } diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index bdde17dd1..6d53db43f 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -7,7 +7,6 @@ use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\CLI\Console; -use Utopia\Database\Adapter\Mongo; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -907,7 +906,7 @@ public function testTransactionStateAfterRetriesExhausted(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter() instanceof Mongo) { + if (!$database->getAdapter()->getSupportForTransactionRetries()) { $this->expectNotToPerformAssertions(); return; } @@ -945,7 +944,7 @@ public function testNestedTransactionState(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter() instanceof Mongo) { + if (!$database->getAdapter()->getSupportForNestedTransactions()) { $this->expectNotToPerformAssertions(); return; }