diff --git a/src/Client.php b/src/Client.php index 121bb81a..ec426f43 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,91 @@ 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, ?int $maxTries = 3) + { + $alias ??= $this->driverSetups->getDefaultAlias(); + $attemptedDrivers = []; + $lastException = null; + + $tries = $maxTries; + + while ($tries > 0) { + 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; + $this->driverSetups->resetDriver($alias); + + --$tries; + } + } + + throw $lastException ?? new ConnectionPoolException('No available drivers'); + } + 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); + } + } + + if (array_key_exists($alias, $this->boundSessions)) { + $session = $this->boundSessions[$alias]; + + return $session->runStatements($statements, $this->defaultTransactionConfiguration); } - return $runner->runStatements($statements); + 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 +200,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/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/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/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/src/Neo4j/RoutingTable.php b/src/Neo4j/RoutingTable.php index cab8e103..f7b1964f 100644 --- a/src/Neo4j/RoutingTable.php +++ b/src/Neo4j/RoutingTable.php @@ -62,49 +62,48 @@ public function getWithRole(?RoutingRoles $role = null): array } /** - * Remove a server from the routing table. - * - * @param string $serverAddress The address to remove - * - * @return RoutingTable A new routing table with the server removed + * Whether the given address appears in this routing table. */ - public function removeServer(string $serverAddress): RoutingTable + public function hasServer(string $serverAddress): bool { - /** @var list, role: string}> $updatedServers */ - $updatedServers = []; - foreach ($this->servers as $server) { - $updatedAddresses = array_filter( - $server['addresses'], - static fn (string $address): bool => $address !== $serverAddress - ); - - if (!empty($updatedAddresses)) { - $updatedServers[] = [ - 'addresses' => array_values($updatedAddresses), - 'role' => $server['role'], - ]; + if (in_array($serverAddress, $server['addresses'], true)) { + return true; } } - return new self($updatedServers, $this->ttl); + return false; } /** - * Check if a server exists in the routing table. - * - * @param string $serverAddress The address to check + * Returns a new table with every occurrence of the address removed from all roles. + * If the address was not present, returns the same instance. * - * @return bool True if the server exists, false otherwise + * @psalm-mutation-free */ - public function hasServer(string $serverAddress): bool + public function removeServer(string $serverAddress): self { + $changed = false; + /** @var list, role: string}> $newServers */ + $newServers = []; foreach ($this->servers as $server) { - if (in_array($serverAddress, $server['addresses'], true)) { - return true; + $addresses = []; + foreach ($server['addresses'] as $address) { + if ($address === $serverAddress) { + $changed = true; + } else { + $addresses[] = $address; + } + } + if ($addresses !== []) { + $newServers[] = ['addresses' => $addresses, 'role' => $server['role']]; } } - return false; + if (!$changed) { + return $this; + } + + return new self($newServers, $this->ttl); } } diff --git a/tests/Unit/ClientExceptionHandlingTest.php b/tests/Unit/ClientExceptionHandlingTest.php new file mode 100644 index 00000000..be40fecb --- /dev/null +++ b/tests/Unit/ClientExceptionHandlingTest.php @@ -0,0 +1,223 @@ + + * + * 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\Contracts\UnmanagedTransactionInterface; +use Laudis\Neo4j\Databags\SessionConfiguration; +use Laudis\Neo4j\Databags\TransactionConfiguration; +use Laudis\Neo4j\Exception\ConnectionPoolException; +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'); + } + + 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()); + } +} diff --git a/tests/Unit/ClientSessionExceptionHandlingTest.php b/tests/Unit/ClientSessionExceptionHandlingTest.php new file mode 100644 index 00000000..35070337 --- /dev/null +++ b/tests/Unit/ClientSessionExceptionHandlingTest.php @@ -0,0 +1,374 @@ + + * + * 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); + } + + 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')]); + } +} 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'); + } +} 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,