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/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..97f8f003 --- /dev/null +++ b/tests/Unit/ClientExceptionHandlingTest.php @@ -0,0 +1,122 @@ + + * + * 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'; + }); + } + + 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/ClientSessionExceptionHandlingTest.php b/tests/Unit/ClientSessionExceptionHandlingTest.php new file mode 100644 index 00000000..03f6cac8 --- /dev/null +++ b/tests/Unit/ClientSessionExceptionHandlingTest.php @@ -0,0 +1,301 @@ + + * + * 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 Laudis\Neo4j\Exception\ConnectionPoolException; +use Laudis\Neo4j\Types\CypherList; +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'); + } + + 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); + } +} diff --git a/tests/Unit/MultiDriverFailoverTest.php b/tests/Unit/MultiDriverFailoverTest.php new file mode 100644 index 00000000..1ea9b047 --- /dev/null +++ b/tests/Unit/MultiDriverFailoverTest.php @@ -0,0 +1,241 @@ + + * + * 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\Databags\SessionConfiguration; +use Laudis\Neo4j\Databags\TransactionConfiguration; +use Laudis\Neo4j\Exception\ConnectionPoolException; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Throwable; + +final class MultiDriverFailoverTest extends TestCase +{ + public function testMultipleDriversWithDifferentPrioritiesWhenHighestPriorityIsDown(): void + { + $driver1 = $this->createMock(DriverInterface::class); + $driver2 = $this->createMock(DriverInterface::class); + $driver3 = $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') + ->willThrowException(new RuntimeException( + 'Cannot connect to host: "node2.example.org". Hosts tried: "192.168.1.2", "node2.example.org"' + )); + + $driver3->method('verifyConnectivity') + ->willReturn(true); + + $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 (array_key_exists(0, $failedDrivers)) { + $this->assertInstanceOf(ConnectionPoolException::class, $failedDrivers[0], 'First driver threw ConnectionPoolException'); + } + if (array_key_exists(1, $failedDrivers)) { + $this->assertInstanceOf(RuntimeException::class, $failedDrivers[1], 'Second driver threw RuntimeException'); + } + } + + 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 testClientThrowsExceptionWhenAllDriversFail(): void + { + $driver1 = $this->createMock(DriverInterface::class); + $driver2 = $this->createMock(DriverInterface::class); + $driver3 = $this->createMock(DriverInterface::class); + + $sessionConfig = SessionConfiguration::default(); + + $driver1->method('verifyConnectivity') + ->willThrowException(new RuntimeException( + '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: "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 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 RuntimeException( + 'Runtime error during connection pool acquire: Cannot create connection' + )); + + $driver2->method('verifyConnectivity') + ->willReturn(true); + + $drivers = [$driver1, $driver2]; + $selectedDriver = null; + $runtimeExceptionCaught = false; + $exceptionMessage = ''; + + foreach ($drivers as $driver) { + try { + if ($driver->verifyConnectivity($sessionConfig)) { + $selectedDriver = $driver; + break; + } + } catch (RuntimeException $e) { + $runtimeExceptionCaught = true; + $exceptionMessage = $e->getMessage(); + continue; + } + } + + $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'); + } +}