From 2cf09a896566f09e28b25d458fe46f667cb2022b Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Wed, 10 Dec 2025 14:26:37 +0530 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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); + } } From bf37634bba35e2928369968dcb9609333ae0a29d Mon Sep 17 00:00:00 2001 From: Ghlen Nagels Date: Fri, 20 Mar 2026 13:55:02 +0530 Subject: [PATCH 07/10] reset the driver during failure in the client --- src/Client.php | 11 ++- src/Common/DriverSetupManager.php | 10 +++ .../ClientSessionExceptionHandlingTest.php | 73 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/Client.php b/src/Client.php index 55f5810b..ec426f43 100644 --- a/src/Client.php +++ b/src/Client.php @@ -113,13 +113,15 @@ private function getSession(?string $alias = null): SessionInterface * * @return T The result of the operation */ - private function executeWithRetry(callable $operation, ?string $alias = null) + private function executeWithRetry(callable $operation, ?string $alias = null, ?int $maxTries = 3) { $alias ??= $this->driverSetups->getDefaultAlias(); $attemptedDrivers = []; $lastException = null; - while (true) { + $tries = $maxTries; + + while ($tries > 0) { try { $driver = $this->driverSetups->getDriver($this->defaultSessionConfiguration, $alias); @@ -134,8 +136,13 @@ private function executeWithRetry(callable $operation, ?string $alias = null) return $operation($session); } catch (ConnectionPoolException|ConnectException $e) { $lastException = $e; + $this->driverSetups->resetDriver($alias); + + --$tries; } } + + throw $lastException ?? new ConnectionPoolException('No available drivers'); } public function runStatements(iterable $statements, ?string $alias = null): CypherList diff --git a/src/Common/DriverSetupManager.php b/src/Common/DriverSetupManager.php index f5d674bf..1602dd5b 100644 --- a/src/Common/DriverSetupManager.php +++ b/src/Common/DriverSetupManager.php @@ -136,6 +136,16 @@ public function getDriver(SessionConfiguration $config, ?string $alias = null): throw new RuntimeException(sprintf('Cannot connect to any server on alias: %s with Uris: (\'%s\')', $alias, implode('\', ', array_unique($urisTried)))); } + /** + * Resets the driver for the given alias, so that it will be recreated on the next call to getDriver, verifying the connection again. + */ + public function resetDriver(?string $alias): void + { + $alias ??= $this->decideAlias($alias); + + unset($this->drivers[$alias]); + } + public function verifyConnectivity(SessionConfiguration $config, ?string $alias = null): bool { try { diff --git a/tests/Unit/ClientSessionExceptionHandlingTest.php b/tests/Unit/ClientSessionExceptionHandlingTest.php index 03f6cac8..35070337 100644 --- a/tests/Unit/ClientSessionExceptionHandlingTest.php +++ b/tests/Unit/ClientSessionExceptionHandlingTest.php @@ -298,4 +298,77 @@ public function testClientRetriesOnAnotherDriverWhenConnectionPoolExceptionOccur $this->assertSame($expectedResult, $result); } + + public function testExecuteWithRetryResetsDriverAfterFailureAndEventuallySucceeds(): 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('First driver session is unavailable')); + + $secondDriverMock->method('createSession') + ->willReturn($successfulSessionMock); + + $expectedResult = $this->createMock(CypherList::class); + $successfulSessionMock->method('runStatements') + ->willReturn($expectedResult); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverSetupManager->expects($this->exactly(2)) + ->method('getDriver') + ->with($sessionConfig, 'default') + ->willReturnOnConsecutiveCalls($firstDriverMock, $secondDriverMock); + + $driverSetupManager->expects($this->once()) + ->method('resetDriver') + ->with('default'); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $result = $client->runStatements([Statement::create('RETURN 1')]); + + $this->assertSame($expectedResult, $result); + } + + public function testExecuteWithRetryThrowsLastConnectionExceptionAfterMaxRetries(): void + { + $driverSetupManager = $this->createMock(DriverSetupManager::class); + $sessionConfig = SessionConfiguration::default(); + $transactionConfig = TransactionConfiguration::default(); + + $connectionException = new ConnectionPoolException('No drivers reachable'); + + $driverSetupManager->method('getDefaultAlias') + ->willReturn('default'); + + $driverSetupManager->expects($this->exactly(3)) + ->method('getDriver') + ->with($sessionConfig, 'default') + ->willThrowException($connectionException); + + $driverSetupManager->expects($this->exactly(3)) + ->method('resetDriver') + ->with('default'); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->expectException(ConnectionPoolException::class); + $this->expectExceptionMessage('No drivers reachable'); + + $client->runStatements([Statement::create('RETURN 1')]); + } } From fabf14771d921638eac42052cf4a9905ec40af59 Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Mon, 30 Mar 2026 10:51:59 +0530 Subject: [PATCH 08/10] fix(neo4j): throw ConnectionPoolException when routing fails; handle in verifyConnectivity and test client recovery --- testkit | 2 +- tests/Unit/ClientExceptionHandlingTest.php | 101 +++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/testkit b/testkit index 9057529d..f9e7590b 160000 --- a/testkit +++ b/testkit @@ -1 +1 @@ -Subproject commit 9057529dbc3f71c05dd557caa0e2245267100413 +Subproject commit f9e7590b44ef983b320fae9adcd0c220b8e02962 diff --git a/tests/Unit/ClientExceptionHandlingTest.php b/tests/Unit/ClientExceptionHandlingTest.php index 97f8f003..be40fecb 100644 --- a/tests/Unit/ClientExceptionHandlingTest.php +++ b/tests/Unit/ClientExceptionHandlingTest.php @@ -15,8 +15,12 @@ use Laudis\Neo4j\Client; use Laudis\Neo4j\Common\DriverSetupManager; +use Laudis\Neo4j\Contracts\DriverInterface; +use Laudis\Neo4j\Contracts\SessionInterface; +use Laudis\Neo4j\Contracts\UnmanagedTransactionInterface; use Laudis\Neo4j\Databags\SessionConfiguration; use Laudis\Neo4j\Databags\TransactionConfiguration; +use Laudis\Neo4j\Exception\ConnectionPoolException; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -119,4 +123,101 @@ public function testClientExceptionIncludesFailedAliasInfo(): void $client->run('RETURN 1 as n', [], 'secondary'); } + + public function testWriteTransactionRecoversAfterConnectionPoolExceptionByResettingDriver(): 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('writeTransaction') + ->willThrowException(new ConnectionPoolException('Write leader unavailable')); + + $secondDriverMock->method('createSession')->willReturn($successfulSessionMock); + $successfulSessionMock->method('writeTransaction')->willReturn('recovered'); + + $driverSetupManager->method('getDefaultAlias')->willReturn('default'); + $driverSetupManager->expects($this->exactly(2)) + ->method('getDriver') + ->with($sessionConfig, 'default') + ->willReturnOnConsecutiveCalls($firstDriverMock, $secondDriverMock); + $driverSetupManager->expects($this->once())->method('resetDriver')->with('default'); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->assertSame( + 'recovered', + $client->writeTransaction(static fn () => 'unused') + ); + } + + public function testReadTransactionRecoversAfterConnectionPoolExceptionByResettingDriver(): 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('readTransaction') + ->willThrowException(new ConnectionPoolException('Read replica unavailable')); + + $secondDriverMock->method('createSession')->willReturn($successfulSessionMock); + $successfulSessionMock->method('readTransaction')->willReturn(42); + + $driverSetupManager->method('getDefaultAlias')->willReturn('default'); + $driverSetupManager->expects($this->exactly(2)) + ->method('getDriver') + ->with($sessionConfig, 'default') + ->willReturnOnConsecutiveCalls($firstDriverMock, $secondDriverMock); + $driverSetupManager->expects($this->once())->method('resetDriver')->with('default'); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->assertSame( + 42, + $client->readTransaction(static fn () => 'unused') + ); + } + + public function testBeginTransactionRecoversAfterConnectionPoolExceptionByResettingDriver(): 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('beginTransaction') + ->willThrowException(new ConnectionPoolException('Cannot begin on stale session')); + + $expectedTsx = $this->createMock(UnmanagedTransactionInterface::class); + $secondDriverMock->method('createSession')->willReturn($successfulSessionMock); + $successfulSessionMock->method('beginTransaction')->willReturn($expectedTsx); + + $driverSetupManager->method('getDefaultAlias')->willReturn('default'); + $driverSetupManager->expects($this->exactly(2)) + ->method('getDriver') + ->with($sessionConfig, 'default') + ->willReturnOnConsecutiveCalls($firstDriverMock, $secondDriverMock); + $driverSetupManager->expects($this->once())->method('resetDriver')->with('default'); + + $client = new Client($driverSetupManager, $sessionConfig, $transactionConfig); + + $this->assertSame($expectedTsx, $client->beginTransaction()); + } } From 1ad3099e7ab1caed9d28a2d8543403f694d5c1e2 Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Mon, 30 Mar 2026 15:40:02 +0530 Subject: [PATCH 09/10] fix(neo4j): improve multi-host failover and client recovery on connection/routing failures --- src/Neo4j/Neo4jConnectionPool.php | 114 +++++++++++++++++++++++++++++- src/Neo4j/RoutingTable.php | 48 +++++++++++++ 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/src/Neo4j/Neo4jConnectionPool.php b/src/Neo4j/Neo4jConnectionPool.php index 278ee797..b086089b 100644 --- a/src/Neo4j/Neo4jConnectionPool.php +++ b/src/Neo4j/Neo4jConnectionPool.php @@ -40,12 +40,14 @@ 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; @@ -132,6 +134,8 @@ public function acquire(SessionConfiguration $config): Generator $latestError = null; if ($table == null) { + $this->getLogger()?->log(LogLevel::DEBUG, 'Routing table not found in cache, fetching new routing table'); + $addresses = $this->getAddresses($this->data->getUri()->getHost()); foreach ($addresses as $address) { $triedAddresses[] = $address; @@ -148,8 +152,18 @@ public function acquire(SessionConfiguration $config): Generator */ $connection = GeneratorHelper::getReturnFromGenerator($pool->acquire($config)); $table = $this->routingTable($connection, $config); + + $this->getLogger()?->log(LogLevel::DEBUG, 'Successfully fetched routing table', [ + 'ttl' => $table->getTtl(), + 'leaders' => $table->getWithRole(RoutingRoles::LEADER()), + 'followers' => $table->getWithRole(RoutingRoles::FOLLOWER()), + 'routers' => $table->getWithRole(RoutingRoles::ROUTE()), + ]); } catch (ConnectException $e) { - // todo - once client side logging is implemented it must be conveyed here. + $this->getLogger()?->log(LogLevel::WARNING, 'Failed to connect to address', [ + 'address' => $address, + 'error' => $e->getMessage(), + ]); $latestError = $e; continue; // We continue if something is wrong with the current server } @@ -163,7 +177,7 @@ public function acquire(SessionConfiguration $config): Generator } if ($table === null) { - throw new ConnectionPoolException(sprintf('Cannot connect to host: "%s". Hosts tried: "%s"', $this->data->getUri()->getHost(), implode('", "', $triedAddresses)), previous: $latestError); + throw new RuntimeException(sprintf('Cannot connect to host: "%s". Hosts tried: "%s"', $this->data->getUri()->getHost(), implode('", "', $triedAddresses)), previous: $latestError); } $server = $this->getNextServer($table, $config->getAccessMode()); @@ -172,6 +186,11 @@ public function acquire(SessionConfiguration $config): Generator $server = $server->withScheme($this->data->getUri()->getScheme()); } + $this->getLogger()?->log(LogLevel::DEBUG, 'Acquiring connection from server', [ + 'server' => (string) $server, + 'access_mode' => $config->getAccessMode()?->getValue(), + ]); + return $this->createOrGetPool($this->data->getUri()->getHost(), $server)->acquire($config); } @@ -180,6 +199,89 @@ public function getLogger(): ?Neo4jLogger return $this->logger; } + /** + * Get the current routing table from cache for the given session configuration. + * + * @return RoutingTable|null The cached routing table, or null if not yet initialized + */ + public function getRoutingTable(SessionConfiguration $config): ?RoutingTable + { + $key = $this->createKey($this->data, $config); + /** @var RoutingTable|null $table */ + $table = $this->cache->get($key); + + return $table; + } + + /** + * Clear the cached routing table for the given session configuration. + * This forces a new routing table to be fetched on the next acquire() call. + */ + public function clearRoutingTable(SessionConfiguration $config): void + { + $key = $this->createKey($this->data, $config); + $deleted = $this->cache->delete($key); + + $this->getLogger()?->log(LogLevel::INFO, 'Cleared routing table from cache', [ + 'key' => $key, + 'deleted' => $deleted, + ]); + } + + /** + * Remove a failed server from the routing table. + * This removes the server from all roles (leader, follower, router) and updates the cache. + * + * @param SessionConfiguration $config The session configuration + * @param string $serverAddress The address of the failed server (e.g., "172.18.0.3:9010") + */ + public function removeFailedServer(SessionConfiguration $config, string $serverAddress): void + { + $key = $this->createKey($this->data, $config); + /** @var RoutingTable|null $table */ + $table = $this->cache->get($key); + + if ($table !== null) { + $this->getLogger()?->log(LogLevel::WARNING, 'Removing failed server from routing table', [ + 'server' => $serverAddress, + ]); + + // Remove the server and update cache + $updatedTable = $table->removeServer($serverAddress); + + // Only update cache if the table actually changed + if ($updatedTable !== $table) { + $this->cache->set($key, $updatedTable, $updatedTable->getTtl()); + + $this->getLogger()?->log(LogLevel::INFO, 'Updated routing table after removing failed server', [ + 'server' => $serverAddress, + 'remaining_leaders' => $updatedTable->getWithRole(RoutingRoles::LEADER()), + 'remaining_followers' => $updatedTable->getWithRole(RoutingRoles::FOLLOWER()), + 'remaining_routers' => $updatedTable->getWithRole(RoutingRoles::ROUTE()), + ]); + } + } + } + + /** + * Check if a server exists in the routing table. + * + * @param SessionConfiguration $config The session configuration + * @param string $serverAddress The address of the server to check + * + * @return bool True if the server exists in the routing table, false otherwise + */ + public function hasServer(SessionConfiguration $config, string $serverAddress): bool + { + $table = $this->getRoutingTable($config); + + if ($table === null) { + return false; + } + + return $table->hasServer($serverAddress); + } + /** * @throws Exception */ @@ -191,6 +293,10 @@ private function getNextServer(RoutingTable $table, ?AccessMode $mode): Uri $servers = $table->getWithRole(RoutingRoles::FOLLOWER()); } + if (count($servers) === 0) { + throw new RuntimeException(sprintf('No servers available for access mode: %s', $mode?->getValue() ?? 'WRITE')); + } + return Uri::create($servers[random_int(0, count($servers) - 1)]); } @@ -250,6 +356,8 @@ private function createKey(ConnectionRequestData $data, ?SessionConfiguration $c public function close(): void { + $this->getLogger()?->log(LogLevel::INFO, 'Closing all connection pools'); + foreach (self::$pools as $pool) { $pool->close(); } diff --git a/src/Neo4j/RoutingTable.php b/src/Neo4j/RoutingTable.php index 4e6d7539..529f66fa 100644 --- a/src/Neo4j/RoutingTable.php +++ b/src/Neo4j/RoutingTable.php @@ -60,4 +60,52 @@ public function getWithRole(?RoutingRoles $role = null): array return array_values(array_unique($tbr)); } + + /** + * Whether the given address appears in this routing table. + */ + public function hasServer(string $serverAddress): bool + { + foreach ($this->servers as $server) { + foreach ($server['addresses'] as $address) { + if ($address === $serverAddress) { + return true; + } + } + } + + return false; + } + + /** + * Returns a new table with every occurrence of the address removed from all roles. + * If the address was not present, returns the same instance. + * + * @psalm-mutation-free + */ + public function removeServer(string $serverAddress): self + { + $changed = false; + /** @var list, role: string}> $newServers */ + $newServers = []; + foreach ($this->servers as $server) { + $addresses = []; + foreach ($server['addresses'] as $address) { + if ($address === $serverAddress) { + $changed = true; + } else { + $addresses[] = $address; + } + } + if ($addresses !== []) { + $newServers[] = ['addresses' => $addresses, 'role' => $server['role']]; + } + } + + if (!$changed) { + return $this; + } + + return new self($newServers, $this->ttl); + } } From a8ee7ea58c04b5aa1881373b0961e39344368e5a Mon Sep 17 00:00:00 2001 From: Pratiksha Zalte Date: Mon, 30 Mar 2026 18:18:12 +0530 Subject: [PATCH 10/10] fixed code standards --- src/Common/SysVSemaphore.php | 3 -- src/Neo4j/Neo4jConnectionPool.php | 2 +- tests/Unit/TypeCasterTest.php | 54 ++++++++++++++++++++++++------- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/Common/SysVSemaphore.php b/src/Common/SysVSemaphore.php index a9886069..c3d872f2 100644 --- a/src/Common/SysVSemaphore.php +++ b/src/Common/SysVSemaphore.php @@ -29,9 +29,6 @@ class SysVSemaphore implements SemaphoreInterface { - /** - * @psalm-suppress UndefinedClass - */ private function __construct( private readonly \SysvSemaphore $semaphore, ) { diff --git a/src/Neo4j/Neo4jConnectionPool.php b/src/Neo4j/Neo4jConnectionPool.php index 6008bd87..edbc2916 100644 --- a/src/Neo4j/Neo4jConnectionPool.php +++ b/src/Neo4j/Neo4jConnectionPool.php @@ -46,7 +46,7 @@ use function random_int; -use \RuntimeException; +use RuntimeException; use function sprintf; use function str_replace; diff --git a/tests/Unit/TypeCasterTest.php b/tests/Unit/TypeCasterTest.php index 40616e10..20a767eb 100644 --- a/tests/Unit/TypeCasterTest.php +++ b/tests/Unit/TypeCasterTest.php @@ -24,15 +24,20 @@ use stdClass; use Stringable; +/** + * @psalm-type ExpectationRow = array + */ final class TypeCasterTest extends TestCase { /** * Complete coverage: every input type × every cast method. * When a cast isn't possible, expected is null (invalid case). * - * @return iterable + * Yields argument lists for {@see testCastMatrix} in order: input, method, expected, class (null unless toClass). + * + * @return Generator */ - public static function provideCastMatrix(): iterable + public static function provideCastMatrix(): Generator { $stringable = new class implements Stringable { public function __toString(): string @@ -89,7 +94,7 @@ public function __toString(): string foreach ($inputs as $inputName => $inputValue) { foreach ($matrix as $method => $expectations) { - $expected = $expectations[$inputName] ?? null; + $expected = self::expectedValueForInput($expectations, $inputName); $key = $inputName.'->'.$method; // Generator must be fresh per test (consumable once) @@ -100,23 +105,48 @@ public function __toString(): string })() : $inputValue; - $row = [ - 'input' => $actualInput, - 'method' => $method, - 'expected' => $expected, - ]; - + $classForRow = null; if ($method === 'toClass') { - $row['class'] = $expectations['_class'][$inputName] ?? stdClass::class; + $classForRow = self::toClassClassNameForInput($expectations, $inputName); } - yield $key => $row; + yield $key => [$actualInput, $method, $expected, $classForRow]; } } } /** - * @return array> + * Values come from {@see getExpectedMatrix()} literals; array access stays `mixed` to Psalm. + * + * @param ExpectationRow $row + * + * @return bool|int|float|string|object|array|array|null + * + * @psalm-suppress MixedReturnStatement + */ + private static function expectedValueForInput(array $row, string $inputName) + { + return $row[$inputName] ?? null; + } + + /** + * @param ExpectationRow $expectations + */ + private static function toClassClassNameForInput(array $expectations, string $inputName): string + { + $maybe = $expectations['_class'] ?? []; + if (!is_array($maybe)) { + return stdClass::class; + } + + /** @var array $classMap */ + $classMap = $maybe; + + return $classMap[$inputName] ?? stdClass::class; + } + + /** + * @return array */ private static function getExpectedMatrix( object $stringable,