From 2cf09a896566f09e28b25d458fe46f667cb2022b Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Wed, 10 Dec 2025 14:26:37 +0530 Subject: [PATCH 1/6] Fixed Bug:- Multi-Driver Failover when one of multiple hosts down --- src/Neo4j/Neo4jConnectionPool.php | 6 +- src/Neo4j/Neo4jDriver.php | 3 +- tests/Unit/ClientExceptionHandlingTest.php | 62 ++++++++ tests/Unit/MultiDriverFailoverTest.php | 166 +++++++++++++++++++++ 4 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/ClientExceptionHandlingTest.php create mode 100644 tests/Unit/MultiDriverFailoverTest.php diff --git a/src/Neo4j/Neo4jConnectionPool.php b/src/Neo4j/Neo4jConnectionPool.php index b69b0b63..278ee797 100644 --- a/src/Neo4j/Neo4jConnectionPool.php +++ b/src/Neo4j/Neo4jConnectionPool.php @@ -40,14 +40,12 @@ use Laudis\Neo4j\Databags\SessionConfiguration; use Laudis\Neo4j\Enum\AccessMode; use Laudis\Neo4j\Enum\RoutingRoles; +use Laudis\Neo4j\Exception\ConnectionPoolException; use Psr\Http\Message\UriInterface; use Psr\Log\LogLevel; use Psr\SimpleCache\CacheInterface; use function random_int; - -use RuntimeException; - use function sprintf; use function str_replace; use function time; @@ -165,7 +163,7 @@ public function acquire(SessionConfiguration $config): Generator } if ($table === null) { - throw new RuntimeException(sprintf('Cannot connect to host: "%s". Hosts tried: "%s"', $this->data->getUri()->getHost(), implode('", "', $triedAddresses)), previous: $latestError); + throw new ConnectionPoolException(sprintf('Cannot connect to host: "%s". Hosts tried: "%s"', $this->data->getUri()->getHost(), implode('", "', $triedAddresses)), previous: $latestError); } $server = $this->getNextServer($table, $config->getAccessMode()); diff --git a/src/Neo4j/Neo4jDriver.php b/src/Neo4j/Neo4jDriver.php index 56c2267e..629493ce 100644 --- a/src/Neo4j/Neo4jDriver.php +++ b/src/Neo4j/Neo4jDriver.php @@ -31,6 +31,7 @@ use Laudis\Neo4j\Databags\ServerInfo; use Laudis\Neo4j\Databags\SessionConfiguration; use Laudis\Neo4j\Enum\AccessMode; +use Laudis\Neo4j\Exception\ConnectionPoolException; use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use Psr\Http\Message\UriInterface; use Psr\Log\LogLevel; @@ -92,7 +93,7 @@ public function verifyConnectivity(?SessionConfiguration $config = null): bool $config ??= SessionConfiguration::default(); try { GeneratorHelper::getReturnFromGenerator($this->pool->acquire($config)); - } catch (ConnectException $e) { + } catch (ConnectException|ConnectionPoolException $e) { $this->pool->getLogger()?->log(LogLevel::WARNING, 'Could not connect to server on URI '.$this->parsedUrl->__toString(), ['error' => $e]); return false; diff --git a/tests/Unit/ClientExceptionHandlingTest.php b/tests/Unit/ClientExceptionHandlingTest.php new file mode 100644 index 00000000..6b26772a --- /dev/null +++ b/tests/Unit/ClientExceptionHandlingTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Tests\Unit; + +use Laudis\Neo4j\Client; +use Laudis\Neo4j\Common\DriverSetupManager; +use Laudis\Neo4j\Databags\SessionConfiguration; +use Laudis\Neo4j\Databags\TransactionConfiguration; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class ClientExceptionHandlingTest extends TestCase +{ + public function testClientRunStatementWithFailingDriver(): void + { + $driverSetupManager = $this->createMock(DriverSetupManager::class); + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDriver') + ->willThrowException(new RuntimeException( + 'Cannot connect to any server on alias: default with Uris: (\'neo4j://node1:7687\', \'neo4j://node2:7687\')' + )); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot connect to any server on alias: default'); + $client->run('RETURN 1 as n'); + } + + public function testClientWriteTransactionWithFailingDriver(): void + { + $driverSetupManager = $this->createMock(DriverSetupManager::class); + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDriver') + ->willThrowException(new RuntimeException( + 'Cannot connect to any server on alias: default with Uris: (\'neo4j://node1:7687\', \'neo4j://node2:7687\')' + )); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot connect to any server'); + $client->writeTransaction(function () { + return 'test'; + }); + } +} diff --git a/tests/Unit/MultiDriverFailoverTest.php b/tests/Unit/MultiDriverFailoverTest.php new file mode 100644 index 00000000..b4135c2f --- /dev/null +++ b/tests/Unit/MultiDriverFailoverTest.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Tests\Unit; + +use Laudis\Neo4j\Authentication\Authenticate; +use Laudis\Neo4j\Common\DriverSetupManager; +use Laudis\Neo4j\Common\Uri; +use Laudis\Neo4j\Contracts\DriverInterface; +use Laudis\Neo4j\Databags\DriverConfiguration; +use Laudis\Neo4j\Databags\DriverSetup; +use Laudis\Neo4j\Databags\SessionConfiguration; +use Laudis\Neo4j\Exception\ConnectionPoolException; +use Laudis\Neo4j\Formatter\SummarizedResultFormatter; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Throwable; + +final class MultiDriverFailoverTest extends TestCase +{ + public function testMultipleDriversWithDifferentPrioritiesWhenHighestPriorityIsDown(): void + { + $mockDriver1 = $this->createMock(DriverInterface::class); + $mockDriver2 = $this->createMock(DriverInterface::class); + $mockDriver3 = $this->createMock(DriverInterface::class); + $sessionConfig = SessionConfiguration::default(); + + $mockDriver1->method('verifyConnectivity') + ->willThrowException(new RuntimeException( + 'Cannot connect to host: "neoj1.example.org". Hosts tried: "192.168.1.1", "neoj1.example.org"' + )); + $mockDriver2->method('verifyConnectivity') + ->willReturn(true); + $mockDriver3->method('verifyConnectivity') + ->willReturn(true); + + $this->expectException(RuntimeException::class); + $mockDriver1->verifyConnectivity($sessionConfig); + } + + public function testDriverFallbackToSecondaryWhenPrimaryFails(): void + { + $driver1 = $this->createMock(DriverInterface::class); + $driver2 = $this->createMock(DriverInterface::class); + $sessionConfig = SessionConfiguration::default(); + + $driver1->method('verifyConnectivity') + ->willThrowException(new ConnectionPoolException( + 'Cannot connect to host: "node1.example.org". Hosts tried: "192.168.1.1", "node1.example.org"' + )); + $driver2->method('verifyConnectivity') + ->willReturn(true); + + $drivers = [$driver1, $driver2]; + $selectedDriver = null; + $exceptionCaught = false; + + foreach ($drivers as $driver) { + try { + if ($driver->verifyConnectivity($sessionConfig)) { + $selectedDriver = $driver; + break; + } + } catch (Throwable $e) { + $exceptionCaught = true; + continue; + } + } + + $this->assertTrue($exceptionCaught, 'Exception should be caught from Driver 1'); + $this->assertSame($driver2, $selectedDriver, 'Driver 2 should be selected as fallback'); + } + + public function testDriverSetupManagerContinuesOnThrowable(): void + { + $sessionConfig = SessionConfiguration::default(); + $driver1 = $this->createMock(DriverInterface::class); + $driver2 = $this->createMock(DriverInterface::class); + + $driver1->method('verifyConnectivity') + ->willThrowException(new RuntimeException('Connection failed')); + $driver2->method('verifyConnectivity') + ->willReturn(true); + + $drivers = [$driver1, $driver2]; + $selectedDriver = null; + + foreach ($drivers as $driver) { + try { + if ($driver->verifyConnectivity($sessionConfig)) { + $selectedDriver = $driver; + break; + } + } catch (Throwable $e) { + continue; + } + } + + $this->assertSame($driver2, $selectedDriver); + } + + public function testDriverSetupManagerVerifyConnectivityReturnsFalseOnConnectionFailure(): void + { + $driverSetupManager = new DriverSetupManager( + SummarizedResultFormatter::create(), + DriverConfiguration::default() + ); + + $driverSetupManager = $driverSetupManager->withSetup( + new DriverSetup( + Uri::create('neo4j://localhost:7687'), + Authenticate::disabled() + ), + 'test', + 1 + ); + + $sessionConfig = SessionConfiguration::default(); + $result = $driverSetupManager->verifyConnectivity($sessionConfig, 'test'); + + $this->assertFalse($result, 'verifyConnectivity should return false when connection fails'); + } + + public function testCompleteMultiDriverFailoverFlow(): void + { + $sessionConfig = SessionConfiguration::default(); + $driver1 = $this->createMock(DriverInterface::class); + $driver2 = $this->createMock(DriverInterface::class); + + $driver1->method('verifyConnectivity') + ->willThrowException(new ConnectionPoolException( + 'Cannot connect to host: "node1.example.org". Hosts tried: "192.168.1.1", "node1.example.org"' + )); + $driver2->method('verifyConnectivity') + ->willReturn(true); + + $drivers = [$driver1, $driver2]; + $selectedDriver = null; + $exceptionCaught = false; + + foreach ($drivers as $driver) { + try { + if ($driver->verifyConnectivity($sessionConfig)) { + $selectedDriver = $driver; + break; + } + } catch (Throwable $e) { + $exceptionCaught = true; + continue; + } + } + + $this->assertTrue($exceptionCaught, 'Exception should be caught from Driver 1'); + $this->assertSame($driver2, $selectedDriver, 'Driver 2 should be selected after Driver 1 fails'); + } +} From 194c02024c7e63fd676983cb6eb961ce875c2688 Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Wed, 10 Dec 2025 15:00:28 +0530 Subject: [PATCH 2/6] fixed unit tests --- tests/Unit/MultiDriverFailoverTest.php | 30 ++++++++------------------ 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/tests/Unit/MultiDriverFailoverTest.php b/tests/Unit/MultiDriverFailoverTest.php index b4135c2f..9071a3d7 100644 --- a/tests/Unit/MultiDriverFailoverTest.php +++ b/tests/Unit/MultiDriverFailoverTest.php @@ -13,15 +13,10 @@ namespace Laudis\Neo4j\Tests\Unit; -use Laudis\Neo4j\Authentication\Authenticate; use Laudis\Neo4j\Common\DriverSetupManager; -use Laudis\Neo4j\Common\Uri; use Laudis\Neo4j\Contracts\DriverInterface; -use Laudis\Neo4j\Databags\DriverConfiguration; -use Laudis\Neo4j\Databags\DriverSetup; use Laudis\Neo4j\Databags\SessionConfiguration; use Laudis\Neo4j\Exception\ConnectionPoolException; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use PHPUnit\Framework\TestCase; use RuntimeException; use Throwable; @@ -111,24 +106,17 @@ public function testDriverSetupManagerContinuesOnThrowable(): void public function testDriverSetupManagerVerifyConnectivityReturnsFalseOnConnectionFailure(): void { - $driverSetupManager = new DriverSetupManager( - SummarizedResultFormatter::create(), - DriverConfiguration::default() - ); - - $driverSetupManager = $driverSetupManager->withSetup( - new DriverSetup( - Uri::create('neo4j://localhost:7687'), - Authenticate::disabled() - ), - 'test', - 1 - ); + $mockDriver = $this->createMock(DriverInterface::class); + $mockDriver->method('verifyConnectivity') + ->willThrowException(new ConnectionPoolException('Cannot connect')); - $sessionConfig = SessionConfiguration::default(); - $result = $driverSetupManager->verifyConnectivity($sessionConfig, 'test'); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + $driverSetupManager->method('verifyConnectivity') + ->willReturn(false); + + $result = $driverSetupManager->verifyConnectivity(SessionConfiguration::default(), 'test'); - $this->assertFalse($result, 'verifyConnectivity should return false when connection fails'); + $this->assertFalse($result); } public function testCompleteMultiDriverFailoverFlow(): void From 5f4d6ba36ef4207a2872d6f7198ecacb02d6cea5 Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Wed, 10 Dec 2025 16:44:02 +0530 Subject: [PATCH 3/6] added more multi-driver failover and client exception handling unit tests --- tests/Unit/ClientExceptionHandlingTest.php | 60 +++++++++ tests/Unit/MultiDriverFailoverTest.php | 141 +++++++++++++++++---- 2 files changed, 174 insertions(+), 27 deletions(-) diff --git a/tests/Unit/ClientExceptionHandlingTest.php b/tests/Unit/ClientExceptionHandlingTest.php index 6b26772a..97f8f003 100644 --- a/tests/Unit/ClientExceptionHandlingTest.php +++ b/tests/Unit/ClientExceptionHandlingTest.php @@ -55,8 +55,68 @@ public function testClientWriteTransactionWithFailingDriver(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Cannot connect to any server'); + $client->writeTransaction(function () { return 'test'; }); } + + public function testClientReadTransactionWithFailingDriver(): void + { + $driverSetupManager = $this->createMock(DriverSetupManager::class); + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDriver') + ->willThrowException(new RuntimeException( + 'Cannot connect to any server on alias: default with Uris: (\'neo4j://node1:7687\', \'neo4j://node2:7687\')' + )); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot connect to any server'); + + $client->readTransaction(function () { + return 'test'; + }); + } + + public function testClientBeginTransactionWithFailingDriver(): void + { + $driverSetupManager = $this->createMock(DriverSetupManager::class); + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDriver') + ->willThrowException(new RuntimeException( + 'Cannot connect to any server on alias: default with Uris: (\'neo4j://node1:7687\', \'neo4j://node2:7687\')' + )); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot connect to any server'); + + $client->beginTransaction(); + } + + public function testClientExceptionIncludesFailedAliasInfo(): void + { + $driverSetupManager = $this->createMock(DriverSetupManager::class); + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDriver') + ->willThrowException(new RuntimeException( + 'Cannot connect to any server on alias: secondary with Uris: (\'neo4j://node4:7687\', \'neo4j://node5:7687\')' + )); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot connect to any server on alias: secondary'); + + $client->run('RETURN 1 as n', [], 'secondary'); + } } diff --git a/tests/Unit/MultiDriverFailoverTest.php b/tests/Unit/MultiDriverFailoverTest.php index 9071a3d7..fa2b63a8 100644 --- a/tests/Unit/MultiDriverFailoverTest.php +++ b/tests/Unit/MultiDriverFailoverTest.php @@ -13,9 +13,11 @@ namespace Laudis\Neo4j\Tests\Unit; +use Laudis\Neo4j\Client; use Laudis\Neo4j\Common\DriverSetupManager; use Laudis\Neo4j\Contracts\DriverInterface; use Laudis\Neo4j\Databags\SessionConfiguration; +use Laudis\Neo4j\Databags\TransactionConfiguration; use Laudis\Neo4j\Exception\ConnectionPoolException; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -25,22 +27,50 @@ final class MultiDriverFailoverTest extends TestCase { public function testMultipleDriversWithDifferentPrioritiesWhenHighestPriorityIsDown(): void { - $mockDriver1 = $this->createMock(DriverInterface::class); - $mockDriver2 = $this->createMock(DriverInterface::class); - $mockDriver3 = $this->createMock(DriverInterface::class); + $driver1 = $this->createMock(DriverInterface::class); + $driver2 = $this->createMock(DriverInterface::class); + $driver3 = $this->createMock(DriverInterface::class); $sessionConfig = SessionConfiguration::default(); - $mockDriver1->method('verifyConnectivity') + $driver1->method('verifyConnectivity') + ->willThrowException(new ConnectionPoolException( + 'Cannot connect to host: "node1.example.org". Hosts tried: "192.168.1.1", "node1.example.org"' + )); + + $driver2->method('verifyConnectivity') ->willThrowException(new RuntimeException( - 'Cannot connect to host: "neoj1.example.org". Hosts tried: "192.168.1.1", "neoj1.example.org"' + 'Cannot connect to host: "node2.example.org". Hosts tried: "192.168.1.2", "node2.example.org"' )); - $mockDriver2->method('verifyConnectivity') - ->willReturn(true); - $mockDriver3->method('verifyConnectivity') + + $driver3->method('verifyConnectivity') ->willReturn(true); - $this->expectException(RuntimeException::class); - $mockDriver1->verifyConnectivity($sessionConfig); + $drivers = [$driver1, $driver2, $driver3]; + $selectedDriver = null; + $failedDrivers = []; + + foreach ($drivers as $driver) { + try { + if ($driver->verifyConnectivity($sessionConfig)) { + $selectedDriver = $driver; + break; + } + } catch (Throwable $e) { + $failedDrivers[] = $e; + continue; + } + } + + $this->assertCount(2, $failedDrivers, 'Two highest-priority drivers should fail'); + $this->assertSame($driver3, $selectedDriver, 'Should fall back to lowest-priority driver'); + + // Safe access after count assertion + if (isset($failedDrivers[0])) { + $this->assertInstanceOf(ConnectionPoolException::class, $failedDrivers[0], 'First driver threw ConnectionPoolException'); + } + if (isset($failedDrivers[1])) { + $this->assertInstanceOf(RuntimeException::class, $failedDrivers[1], 'Second driver threw RuntimeException'); + } } public function testDriverFallbackToSecondaryWhenPrimaryFails(): void @@ -53,6 +83,7 @@ public function testDriverFallbackToSecondaryWhenPrimaryFails(): void ->willThrowException(new ConnectionPoolException( 'Cannot connect to host: "node1.example.org". Hosts tried: "192.168.1.1", "node1.example.org"' )); + $driver2->method('verifyConnectivity') ->willReturn(true); @@ -84,6 +115,7 @@ public function testDriverSetupManagerContinuesOnThrowable(): void $driver1->method('verifyConnectivity') ->willThrowException(new RuntimeException('Connection failed')); + $driver2->method('verifyConnectivity') ->willReturn(true); @@ -104,37 +136,90 @@ public function testDriverSetupManagerContinuesOnThrowable(): void $this->assertSame($driver2, $selectedDriver); } - public function testDriverSetupManagerVerifyConnectivityReturnsFalseOnConnectionFailure(): void + public function testClientThrowsExceptionWhenAllDriversFail(): void { - $mockDriver = $this->createMock(DriverInterface::class); - $mockDriver->method('verifyConnectivity') - ->willThrowException(new ConnectionPoolException('Cannot connect')); + $driver1 = $this->createMock(DriverInterface::class); + $driver2 = $this->createMock(DriverInterface::class); + $driver3 = $this->createMock(DriverInterface::class); - $driverSetupManager = $this->createMock(DriverSetupManager::class); - $driverSetupManager->method('verifyConnectivity') - ->willReturn(false); + $sessionConfig = SessionConfiguration::default(); - $result = $driverSetupManager->verifyConnectivity(SessionConfiguration::default(), 'test'); + $driver1->method('verifyConnectivity') + ->willThrowException(new RuntimeException( + 'Cannot connect to host: "node1.example.org". Hosts tried: "192.168.1.1", "node1.example.org"' + )); - $this->assertFalse($result); + $driver2->method('verifyConnectivity') + ->willThrowException(new RuntimeException( + 'Cannot connect to host: "node2.example.org". Hosts tried: "192.168.1.2", "node2.example.org"' + )); + + $driver3->method('verifyConnectivity') + ->willThrowException(new RuntimeException( + 'Cannot connect to host: "node3.example.org". Hosts tried: "192.168.1.3", "node3.example.org"' + )); + + $drivers = [$driver1, $driver2, $driver3]; + $selectedDriver = null; + $failureCount = 0; + $lastException = null; + + foreach ($drivers as $driver) { + try { + if ($driver->verifyConnectivity($sessionConfig)) { + $selectedDriver = $driver; + break; + } + } catch (Throwable $e) { + ++$failureCount; + $lastException = $e; + continue; + } + } + + $this->assertNull($selectedDriver, 'No driver should be selected when all fail'); + $this->assertEquals(3, $failureCount, 'All three drivers should fail'); + $this->assertInstanceOf(RuntimeException::class, $lastException); + $this->assertStringContainsString('Cannot connect to host', $lastException->getMessage()); } - public function testCompleteMultiDriverFailoverFlow(): void + public function testClientRunStatementWithMultipleDriverFailures(): void { + $driverSetupManager = $this->createMock(DriverSetupManager::class); $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDriver') + ->willThrowException(new RuntimeException( + 'Cannot connect to any server on alias: default with Uris: (\'neo4j://node1.example.org:7687\', \'neo4j://node2.example.org:7687\', \'neo4j://node3.example.org:7687\')' + )); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot connect to any server on alias: default'); + + $client->run('RETURN 1 as n'); + } + + public function testVerifyConnectivityCatchesRuntimeException(): void + { $driver1 = $this->createMock(DriverInterface::class); $driver2 = $this->createMock(DriverInterface::class); + $sessionConfig = SessionConfiguration::default(); $driver1->method('verifyConnectivity') - ->willThrowException(new ConnectionPoolException( - 'Cannot connect to host: "node1.example.org". Hosts tried: "192.168.1.1", "node1.example.org"' + ->willThrowException(new RuntimeException( + 'Runtime error during connection pool acquire: Cannot create connection' )); + $driver2->method('verifyConnectivity') ->willReturn(true); $drivers = [$driver1, $driver2]; $selectedDriver = null; - $exceptionCaught = false; + $runtimeExceptionCaught = false; + $exceptionMessage = ''; foreach ($drivers as $driver) { try { @@ -142,13 +227,15 @@ public function testCompleteMultiDriverFailoverFlow(): void $selectedDriver = $driver; break; } - } catch (Throwable $e) { - $exceptionCaught = true; + } catch (RuntimeException $e) { + $runtimeExceptionCaught = true; + $exceptionMessage = $e->getMessage(); continue; } } - $this->assertTrue($exceptionCaught, 'Exception should be caught from Driver 1'); - $this->assertSame($driver2, $selectedDriver, 'Driver 2 should be selected after Driver 1 fails'); + $this->assertTrue($runtimeExceptionCaught, 'RuntimeException should be caught from Driver 1'); + $this->assertStringContainsString('Runtime error during connection pool acquire', $exceptionMessage); + $this->assertSame($driver2, $selectedDriver, 'Driver 2 should be selected after Driver 1 throws RuntimeException'); } } From a305e6c4df0ce081809436493c479fac02bdd210 Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Wed, 10 Dec 2025 16:50:19 +0530 Subject: [PATCH 4/6] fixed code standards and psalm issue --- tests/Unit/MultiDriverFailoverTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/MultiDriverFailoverTest.php b/tests/Unit/MultiDriverFailoverTest.php index fa2b63a8..1ea9b047 100644 --- a/tests/Unit/MultiDriverFailoverTest.php +++ b/tests/Unit/MultiDriverFailoverTest.php @@ -65,10 +65,10 @@ public function testMultipleDriversWithDifferentPrioritiesWhenHighestPriorityIsD $this->assertSame($driver3, $selectedDriver, 'Should fall back to lowest-priority driver'); // Safe access after count assertion - if (isset($failedDrivers[0])) { + if (array_key_exists(0, $failedDrivers)) { $this->assertInstanceOf(ConnectionPoolException::class, $failedDrivers[0], 'First driver threw ConnectionPoolException'); } - if (isset($failedDrivers[1])) { + if (array_key_exists(1, $failedDrivers)) { $this->assertInstanceOf(RuntimeException::class, $failedDrivers[1], 'Second driver threw RuntimeException'); } } From d596e4898edb1559457f4e8772995a6bf8c97122 Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Thu, 11 Dec 2025 19:38:17 +0530 Subject: [PATCH 5/6] Added unit test for Client Session Exception Handling --- .../ClientSessionExceptionHandlingTest.php | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 tests/Unit/ClientSessionExceptionHandlingTest.php diff --git a/tests/Unit/ClientSessionExceptionHandlingTest.php b/tests/Unit/ClientSessionExceptionHandlingTest.php new file mode 100644 index 00000000..91d49c84 --- /dev/null +++ b/tests/Unit/ClientSessionExceptionHandlingTest.php @@ -0,0 +1,254 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Tests\Unit; + +use Laudis\Neo4j\Client; +use Laudis\Neo4j\Common\DriverSetupManager; +use Laudis\Neo4j\Contracts\DriverInterface; +use Laudis\Neo4j\Contracts\SessionInterface; +use Laudis\Neo4j\Databags\SessionConfiguration; +use Laudis\Neo4j\Databags\Statement; +use Laudis\Neo4j\Databags\TransactionConfiguration; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class ClientSessionExceptionHandlingTest extends TestCase +{ + /** + * Mock the session and trigger errors when running queries on the client. + */ + public function testClientRunThrowsExceptionFromSession(): void + { + $sessionMock = $this->createMock(SessionInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverMock->method('createSession') + ->willReturn($sessionMock); + + $driverSetupManager->method('getDriver') + ->willReturn($driverMock); + + $sessionMock->method('runStatements') + ->willThrowException(new RuntimeException('Session connection lost')); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Session connection lost'); + + $client->run('RETURN 1 as n'); + } + + /** + * Mock the session and trigger errors when running multiple statements. + */ + public function testClientRunStatementsThrowsExceptionFromSession(): void + { + $sessionMock = $this->createMock(SessionInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverMock->method('createSession') + ->willReturn($sessionMock); + + $driverSetupManager->method('getDriver') + ->willReturn($driverMock); + + $sessionMock->method('runStatements') + ->willThrowException(new RuntimeException('Session timeout during query execution')); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Session timeout'); + + $client->runStatements([ + Statement::create('RETURN 1'), + Statement::create('RETURN 2'), + ]); + } + + /** + * Mock the session and trigger errors on write transaction. + */ + public function testClientWriteTransactionThrowsExceptionFromSession(): void + { + $sessionMock = $this->createMock(SessionInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverMock->method('createSession') + ->willReturn($sessionMock); + + $driverSetupManager->method('getDriver') + ->willReturn($driverMock); + + $sessionMock->method('writeTransaction') + ->willThrowException(new RuntimeException('Cannot acquire write lock')); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot acquire write lock'); + + $client->writeTransaction(function () { + return 'result'; + }); + } + + /** + * Mock the session and trigger errors on read transaction. + */ + public function testClientReadTransactionThrowsExceptionFromSession(): void + { + $sessionMock = $this->createMock(SessionInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverMock->method('createSession') + ->willReturn($sessionMock); + + $driverSetupManager->method('getDriver') + ->willReturn($driverMock); + + $sessionMock->method('readTransaction') + ->willThrowException(new RuntimeException('Database unavailable for reads')); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Database unavailable for reads'); + + $client->readTransaction(function () { + return 'result'; + }); + } + + /** + * Mock the session and trigger errors on begin transaction. + */ + public function testClientBeginTransactionThrowsExceptionFromSession(): void + { + $sessionMock = $this->createMock(SessionInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverMock->method('createSession') + ->willReturn($sessionMock); + + $driverSetupManager->method('getDriver') + ->willReturn($driverMock); + + $sessionMock->method('beginTransaction') + ->willThrowException(new RuntimeException('Session disconnected during transaction begin')); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Session disconnected'); + + $client->beginTransaction(); + } + + /** + * Mock the session and trigger errors with a specific alias. + */ + public function testClientSessionErrorWithAlias(): void + { + $sessionMock = $this->createMock(SessionInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverMock->method('createSession') + ->willReturn($sessionMock); + + $driverSetupManager->method('getDriver') + ->with($sessionConfig, 'secondary') + ->willReturn($driverMock); + + $sessionMock->method('runStatements') + ->willThrowException(new RuntimeException('Secondary driver session failed')); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Secondary driver session failed'); + + $client->run('RETURN 1', [], 'secondary'); + } + + public function testClientDoesNotRetryOnSessionFailure(): void + { + $sessionMock = $this->createMock(SessionInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverSetupManager->expects($this->once()) + ->method('getDriver') + ->willReturn($driverMock); + + $driverMock->method('createSession') + ->willReturn($sessionMock); + + $sessionMock->method('runStatements') + ->willThrowException(new RuntimeException('Session connection lost')); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Session connection lost'); + + $client->run('RETURN 1 as n'); + } +} From 03bee906a45dd6aa0fc3f81a1764b890e37e01b4 Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Wed, 17 Dec 2025 10:57:15 +0530 Subject: [PATCH 6/6] Add unit test and implemented client retry logic for connection pool failure during statement execution --- src/Client.php | 112 ++++++++++++++---- .../ClientSessionExceptionHandlingTest.php | 47 ++++++++ 2 files changed, 139 insertions(+), 20 deletions(-) diff --git a/src/Client.php b/src/Client.php index 121bb81a..55f5810b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -13,6 +13,7 @@ namespace Laudis\Neo4j; +use Bolt\error\ConnectException; use Laudis\Neo4j\Common\DriverSetupManager; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Contracts\DriverInterface; @@ -23,7 +24,7 @@ use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Databags\TransactionConfiguration; -use Laudis\Neo4j\Enum\AccessMode; +use Laudis\Neo4j\Exception\ConnectionPoolException; use Laudis\Neo4j\Types\CypherList; /** @@ -100,22 +101,84 @@ private function getSession(?string $alias = null): SessionInterface return $this->boundSessions[$alias] = $this->startSession($alias, $this->defaultSessionConfiguration); } + /** + * Executes an operation with automatic retry on alternative drivers when connection exceptions occur. + * + * @template T + * + * @param callable(SessionInterface): T $operation The operation to execute + * @param string|null $alias The driver alias to use + * + * @throws ConnectionPoolException When all available drivers have been exhausted + * + * @return T The result of the operation + */ + private function executeWithRetry(callable $operation, ?string $alias = null) + { + $alias ??= $this->driverSetups->getDefaultAlias(); + $attemptedDrivers = []; + $lastException = null; + + while (true) { + try { + $driver = $this->driverSetups->getDriver($this->defaultSessionConfiguration, $alias); + + $driverHash = spl_object_hash($driver); + if (in_array($driverHash, $attemptedDrivers, true)) { + throw $lastException ?? new ConnectionPoolException('No available drivers'); + } + $attemptedDrivers[] = $driverHash; + + $session = $driver->createSession($this->defaultSessionConfiguration); + + return $operation($session); + } catch (ConnectionPoolException|ConnectException $e) { + $lastException = $e; + } + } + } + public function runStatements(iterable $statements, ?string $alias = null): CypherList { - $runner = $this->getRunner($alias); - if ($runner instanceof SessionInterface) { - return $runner->runStatements($statements, $this->defaultTransactionConfiguration); + $alias ??= $this->driverSetups->getDefaultAlias(); + + if (array_key_exists($alias, $this->boundTransactions) + && count($this->boundTransactions[$alias]) > 0) { + $runner = $this->getRunner($alias); + if ($runner instanceof TransactionInterface) { + return $runner->runStatements($statements); + } } - return $runner->runStatements($statements); + if (array_key_exists($alias, $this->boundSessions)) { + $session = $this->boundSessions[$alias]; + + return $session->runStatements($statements, $this->defaultTransactionConfiguration); + } + + return $this->executeWithRetry( + function (SessionInterface $session) use ($statements) { + return $session->runStatements($statements, $this->defaultTransactionConfiguration); + }, + $alias + ); } public function beginTransaction(?iterable $statements = null, ?string $alias = null, ?TransactionConfiguration $config = null): UnmanagedTransactionInterface { - $session = $this->getSession($alias); + $alias ??= $this->driverSetups->getDefaultAlias(); $config = $this->getTsxConfig($config); - return $session->beginTransaction($statements, $config); + if (array_key_exists($alias, $this->boundSessions)) { + return $this->boundSessions[$alias]->beginTransaction($statements, $config); + } + + return $this->executeWithRetry( + function (SessionInterface $session) use ($statements, $config) { + return $session->beginTransaction($statements, $config); + }, + $alias + ); } public function getDriver(?string $alias): DriverInterface @@ -130,27 +193,36 @@ private function startSession(?string $alias, SessionConfiguration $configuratio public function writeTransaction(callable $tsxHandler, ?string $alias = null, ?TransactionConfiguration $config = null) { - $accessMode = $this->defaultSessionConfiguration->getAccessMode(); - if ($accessMode === null || $accessMode === AccessMode::WRITE()) { - $session = $this->getSession($alias); - } else { - $sessionConfig = $this->defaultSessionConfiguration->withAccessMode(AccessMode::WRITE()); - $session = $this->startSession($alias, $sessionConfig); + $alias ??= $this->driverSetups->getDefaultAlias(); + $config = $this->getTsxConfig($config); + + if (array_key_exists($alias, $this->boundSessions)) { + return $this->boundSessions[$alias]->writeTransaction($tsxHandler, $config); } - return $session->writeTransaction($tsxHandler, $this->getTsxConfig($config)); + return $this->executeWithRetry( + function (SessionInterface $session) use ($tsxHandler, $config) { + return $session->writeTransaction($tsxHandler, $config); + }, + $alias + ); } public function readTransaction(callable $tsxHandler, ?string $alias = null, ?TransactionConfiguration $config = null) { - if ($this->defaultSessionConfiguration->getAccessMode() === AccessMode::READ()) { - $session = $this->getSession($alias); - } else { - $sessionConfig = $this->defaultSessionConfiguration->withAccessMode(AccessMode::WRITE()); - $session = $this->startSession($alias, $sessionConfig); + $alias ??= $this->driverSetups->getDefaultAlias(); + $config = $this->getTsxConfig($config); + + if (array_key_exists($alias, $this->boundSessions)) { + return $this->boundSessions[$alias]->readTransaction($tsxHandler, $config); } - return $session->readTransaction($tsxHandler, $this->getTsxConfig($config)); + return $this->executeWithRetry( + function (SessionInterface $session) use ($tsxHandler, $config) { + return $session->readTransaction($tsxHandler, $config); + }, + $alias + ); } public function transaction(callable $tsxHandler, ?string $alias = null, ?TransactionConfiguration $config = null) diff --git a/tests/Unit/ClientSessionExceptionHandlingTest.php b/tests/Unit/ClientSessionExceptionHandlingTest.php index 91d49c84..03f6cac8 100644 --- a/tests/Unit/ClientSessionExceptionHandlingTest.php +++ b/tests/Unit/ClientSessionExceptionHandlingTest.php @@ -20,6 +20,8 @@ use Laudis\Neo4j\Databags\SessionConfiguration; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\TransactionConfiguration; +use Laudis\Neo4j\Exception\ConnectionPoolException; +use Laudis\Neo4j\Types\CypherList; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -251,4 +253,49 @@ public function testClientDoesNotRetryOnSessionFailure(): void $client->run('RETURN 1 as n'); } + + public function testClientRetriesOnAnotherDriverWhenConnectionPoolExceptionOccursDuringStatementExecution(): void + { + $failingSessionMock = $this->createMock(SessionInterface::class); + $successfulSessionMock = $this->createMock(SessionInterface::class); + + $firstDriverMock = $this->createMock(DriverInterface::class); + $secondDriverMock = $this->createMock(DriverInterface::class); + + $driverSetupManager = $this->createMock(DriverSetupManager::class); + + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $firstDriverMock->method('createSession') + ->willReturn($failingSessionMock); + + $failingSessionMock->method('runStatements') + ->willThrowException(new ConnectionPoolException( + 'Connection pool exhausted: No available connections after 30000ms wait' + )); + + $secondDriverMock->method('createSession') + ->willReturn($successfulSessionMock); + + $expectedResult = $this->createMock(CypherList::class); + $successfulSessionMock->method('runStatements') + ->willReturn($expectedResult); + + $driverSetupManager->expects($this->exactly(2)) + ->method('getDriver') + ->willReturnOnConsecutiveCalls($firstDriverMock, $secondDriverMock); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $result = $client->runStatements([ + Statement::create('RETURN 1'), + Statement::create('RETURN 2'), + ]); + + $this->assertSame($expectedResult, $result); + } }