Skip to content

Commit aad4cb6

Browse files
committed
- Added 'Feature:API:Driver:GetServerInfo' => true in the driver for Testkit
- Enables retrieval of server information via the GetServerInfo request
1 parent b5b55e6 commit aad4cb6

File tree

9 files changed

+323
-15
lines changed

9 files changed

+323
-15
lines changed

src/Bolt/BoltConnection.php

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ class BoltConnection implements ConnectionInterface
6565
*/
6666
private array $subscribedResults = [];
6767
private bool $inTransaction = false;
68+
/** @var array<string, bool> Track if this connection was ever used for a query */
69+
private array $connectionUsed = [
70+
'reader' => false,
71+
'writer' => false,
72+
];
6873

6974
/**
7075
* @return array{0: V4_4|V5|V5_1|V5_2|V5_3|V5_4|null, 1: Connection}
@@ -254,6 +259,12 @@ public function run(
254259
?AccessMode $mode,
255260
?iterable $tsxMetadata,
256261
): array {
262+
if ($mode === AccessMode::WRITE()) {
263+
$this->connectionUsed['writer'] = true;
264+
} else {
265+
$this->connectionUsed['reader'] = true;
266+
}
267+
257268
if ($this->isInTransaction()) {
258269
$extra = [];
259270
} else {
@@ -323,18 +334,24 @@ public function close(): void
323334
{
324335
try {
325336
if ($this->isOpen()) {
326-
if ($this->isStreaming()) {
337+
if ($this->isStreaming() && ($this->connectionUsed['reader'] || $this->connectionUsed['writer'])) {
327338
$this->discardUnconsumedResults();
328339
}
329-
$message = $this->messageFactory->createGoodbyeMessage();
330-
$message->send();
331340

332-
unset($this->boltProtocol); // has to be set to null as the sockets don't recover nicely contrary to what the underlying code might lead you to believe;
341+
// Only send GOODBYE if the connection was ever used
342+
if ($this->connectionUsed['reader'] || $this->connectionUsed['writer']) {
343+
$message = $this->messageFactory->createGoodbyeMessage();
344+
$message->send();
345+
}
346+
347+
unset($this->boltProtocol);
333348
}
334349
} catch (Throwable) {
350+
// ignore, but could log
335351
}
336352
}
337353

354+
338355
private function buildRunExtra(
339356
?string $database,
340357
?float $timeout,
@@ -437,20 +454,27 @@ public function assertNoFailure(Response $response): void
437454
public function discardUnconsumedResults(): void
438455
{
439456
$this->logger?->log(LogLevel::DEBUG, 'Discarding unconsumed results');
457+
440458
$this->subscribedResults = array_values(array_filter(
441459
$this->subscribedResults,
442460
static fn (WeakReference $ref): bool => $ref->get() !== null
443461
));
444462

445463
if (empty($this->subscribedResults)) {
446464
$this->logger?->log(LogLevel::DEBUG, 'No unconsumed results to discard');
447-
448465
return;
449466
}
450467

451468
$state = $this->getServerState();
452469
$this->logger?->log(LogLevel::DEBUG, "Server state before discard: {$state}");
453470

471+
// Skip discard if this connection was never used
472+
if (!$this->connectionUsed['reader'] && !$this->connectionUsed['writer']) {
473+
$this->logger?->log(LogLevel::DEBUG, 'Skipping discard - connection never used');
474+
$this->subscribedResults = [];
475+
return;
476+
}
477+
454478
try {
455479
if (in_array($state, ['STREAMING', 'TX_STREAMING'], true)) {
456480
$this->discard(null);

src/Bolt/Session.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public function __construct(
5858
private readonly SummarizedResultFormatter $formatter,
5959
) {
6060
$this->bookmarkHolder = new BookmarkHolder(Bookmark::from($config->getBookmarks()));
61+
6162
}
6263

6364
/**

src/Contracts/DriverInterface.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace Laudis\Neo4j\Contracts;
1515

16+
use Laudis\Neo4j\Databags\ServerInfo;
1617
use Laudis\Neo4j\Databags\SessionConfiguration;
1718
use Laudis\Neo4j\Types\CypherList;
1819
use Laudis\Neo4j\Types\CypherMap;

testkit-backend/features.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@
221221
// time period. On timout, the driver should remove the server from its
222222
// routing table and assume all other connections to the server are dead
223223
// as well.
224-
'ConfHint:connection.recv_timeout_seconds' => true,
224+
'ConfHint:connection.recv_timeout_seconds' => false,
225225

226226
// === BACKEND FEATURES FOR TESTING ===
227227
// The backend understands the FakeTimeInstall, FakeTimeUninstall and
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Neo4j PHP Client and Driver package.
7+
*
8+
* (c) Nagels <https://nagels.tech>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Laudis\Neo4j\TestkitBackend\Handlers;
15+
16+
use Exception;
17+
use Laudis\Neo4j\Bolt\BoltDriver;
18+
use Laudis\Neo4j\Common\GeneratorHelper;
19+
use Laudis\Neo4j\Databags\Neo4jError;
20+
use Laudis\Neo4j\Databags\ServerInfo;
21+
use Laudis\Neo4j\Databags\SessionConfiguration;
22+
use Laudis\Neo4j\Exception\Neo4jException;
23+
use Laudis\Neo4j\Exception\TransactionException;
24+
use Laudis\Neo4j\Neo4j\Neo4jDriver;
25+
use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface;
26+
use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface;
27+
use Laudis\Neo4j\TestkitBackend\MainRepository;
28+
use Laudis\Neo4j\TestkitBackend\Requests\GetServerInfoRequest;
29+
use Laudis\Neo4j\TestkitBackend\Responses\DriverErrorResponse;
30+
use Laudis\Neo4j\TestkitBackend\Responses\ServerInfoResponse;
31+
use ReflectionClass;
32+
use Symfony\Component\Uid\Uuid;
33+
34+
/**
35+
* @implements RequestHandlerInterface<GetServerInfoRequest>
36+
*/
37+
final class GetServerInfo implements RequestHandlerInterface
38+
{
39+
public function __construct(
40+
private MainRepository $repository
41+
) {}
42+
43+
/**
44+
* @param GetServerInfoRequest $request
45+
*/
46+
public function handle($request): TestkitResponseInterface
47+
{
48+
try {
49+
$driver = $this->repository->getDriver($request->getDriverId());
50+
51+
if ($driver instanceof BoltDriver || $driver instanceof Neo4jDriver) {
52+
return $this->getServerInfoFromDriver($driver);
53+
}
54+
55+
return $this->getServerInfoFromSession($driver);
56+
57+
} catch (Exception $e) {
58+
$uuid = Uuid::v4();
59+
60+
if ($e instanceof Neo4jException || $e instanceof TransactionException) {
61+
return new DriverErrorResponse($uuid, $e);
62+
}
63+
64+
$neo4jError = new Neo4jError(
65+
$e->getMessage(),
66+
(string) $e->getCode(),
67+
'DatabaseError',
68+
'Service',
69+
'Service Unavailable'
70+
);
71+
72+
return new DriverErrorResponse($uuid, new Neo4jException([$neo4jError], $e));
73+
}
74+
}
75+
76+
private function getServerInfoFromDriver($driver): ServerInfoResponse
77+
{
78+
$connection = null;
79+
$pool = null;
80+
81+
try {
82+
$pool = $this->getConnectionPool($driver);
83+
$connection = $this->acquireConnectionFromPool($pool, SessionConfiguration::default());
84+
return new ServerInfoResponse($this->extractServerInfo($connection));
85+
} finally {
86+
if ($connection !== null && $pool !== null) {
87+
$pool->release($connection);
88+
}
89+
}
90+
}
91+
92+
/**
93+
* Extracts connection pool from driver using reflection.
94+
*/
95+
private function getConnectionPool($driver)
96+
{
97+
$reflection = new ReflectionClass($driver);
98+
99+
foreach (['pool', 'connectionPool', '_pool', 'connections'] as $propertyName) {
100+
if ($reflection->hasProperty($propertyName)) {
101+
$property = $reflection->getProperty($propertyName);
102+
$property->setAccessible(true);
103+
$pool = $property->getValue($driver);
104+
105+
if ($pool !== null) {
106+
return $pool;
107+
}
108+
}
109+
}
110+
111+
throw new Exception('Could not access connection pool from driver');
112+
}
113+
114+
/**
115+
* Acquire a connection from the pool.
116+
*/
117+
private function acquireConnectionFromPool($pool, SessionConfiguration $sessionConfig)
118+
{
119+
// Fail early if routing table has no readers
120+
if (method_exists($pool, 'getRoutingTable')) {
121+
$routingTable = $pool->getRoutingTable();
122+
if ($routingTable !== null && empty($routingTable->getReaders())) {
123+
throw new Neo4jException([
124+
new Neo4jError(
125+
'No readers available in routing table',
126+
'N/A',
127+
'ClientError',
128+
'Routing',
129+
'RoutingTable'
130+
)
131+
]);
132+
}
133+
}
134+
135+
$connectionGenerator = $pool->acquire($sessionConfig);
136+
$connection = GeneratorHelper::getReturnFromGenerator($connectionGenerator);
137+
138+
if ($connection === null) {
139+
throw new Exception('Connection pool returned no connections');
140+
}
141+
142+
return $connection;
143+
}
144+
145+
/**
146+
* Extract server information from an active connection.
147+
*/
148+
private function extractServerInfo($connection): ServerInfo
149+
{
150+
foreach (['getServerAddress', 'getServerAgent', 'getProtocol'] as $method) {
151+
if (!method_exists($connection, $method)) {
152+
throw new Exception("Connection does not support {$method}()");
153+
}
154+
}
155+
156+
$address = $connection->getServerAddress();
157+
$agent = $connection->getServerAgent();
158+
$protocol = $connection->getProtocol();
159+
160+
if (empty($address) || empty($agent)) {
161+
throw new Exception('Server info is incomplete');
162+
}
163+
164+
return new ServerInfo($address, $protocol, $agent);
165+
}
166+
167+
private function getServerInfoFromSession($driver): ServerInfoResponse
168+
{
169+
if (method_exists($driver, 'session')) {
170+
$session = $driver->session();
171+
} elseif (method_exists($driver, 'createSession')) {
172+
$session = $driver->createSession();
173+
} elseif (method_exists($driver, 'newSession')) {
174+
$session = $driver->newSession();
175+
} else {
176+
throw new Exception('No session creation method found on driver');
177+
}
178+
179+
try {
180+
$result = $session->run('RETURN 1');
181+
return new ServerInfoResponse($result->summary()->getServerInfo());
182+
} finally {
183+
$session->close();
184+
}
185+
}
186+
}

testkit-backend/src/RequestFactory.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace Laudis\Neo4j\TestkitBackend;
1515

16+
use Laudis\Neo4j\TestkitBackend\Requests\GetServerInfoRequest;
1617
use function is_string;
1718

1819
use Laudis\Neo4j\TestkitBackend\Requests\AuthorizationTokenRequest;
@@ -70,6 +71,7 @@ final class RequestFactory
7071
'RetryableNegative' => RetryableNegativeRequest::class,
7172
'ForcedRoutingTableUpdate' => ForcedRoutingTableUpdateRequest::class,
7273
'GetRoutingTable' => GetRoutingTableRequest::class,
74+
'GetServerInfo' => GetServerInfoRequest::class,
7375
];
7476

7577
/**
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Neo4j PHP Client and Driver package.
7+
*
8+
* (c) Nagels <https://nagels.tech>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Laudis\Neo4j\TestkitBackend\Requests;
15+
16+
use Symfony\Component\Uid\Uuid;
17+
18+
final class GetServerInfoRequest
19+
{
20+
private Uuid $driverId;
21+
22+
public function __construct(Uuid $driverId)
23+
{
24+
$this->driverId = $driverId;
25+
}
26+
27+
public function getDriverId(): Uuid
28+
{
29+
return $this->driverId;
30+
}
31+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Neo4j PHP Client and Driver package.
7+
*
8+
* (c) Nagels <https://nagels.tech>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Laudis\Neo4j\TestkitBackend\Responses;
15+
16+
use Laudis\Neo4j\Databags\ServerInfo;
17+
use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface;
18+
19+
/**
20+
* Response containing server information.
21+
*/
22+
final class ServerInfoResponse implements TestkitResponseInterface
23+
{
24+
private string $address;
25+
private string $agent;
26+
private string $protocolVersion;
27+
28+
public function __construct(ServerInfo $serverInfo)
29+
{
30+
$uri = $serverInfo->getAddress();
31+
$this->address = $uri->getHost() . ':' . $uri->getPort();
32+
33+
$this->agent = $serverInfo->getAgent();
34+
35+
$protocol = $serverInfo->getProtocol();
36+
if (method_exists($protocol, 'getValue')) {
37+
$this->protocolVersion = (string) $protocol->getValue();
38+
} else {
39+
$this->protocolVersion = (string) $protocol;
40+
}
41+
}
42+
43+
public function jsonSerialize(): array
44+
{
45+
return [
46+
'name' => 'ServerInfo',
47+
'data' => [
48+
'address' => $this->address,
49+
'agent' => $this->agent,
50+
'protocolVersion' => $this->protocolVersion,
51+
],
52+
];
53+
}
54+
}

0 commit comments

Comments
 (0)