Skip to content

Commit abfe5a8

Browse files
authored
Connection Receive Timeout Configuration Hint Implementation (#279)
- Removed conversion of connection/timeout errors to NotALeader in BoltResult.php and BoltMessage.php; original exceptions are rethrown after invalidating the connection. - Updated Session.isConnectionError() to check for BoltConnectException and removed NotALeader from it. - Replaced catch (Throwable) with catch (BoltConnectException) in BoltConnection.close() and discardUnconsumedResults(). - Added a BoltConnectException handler in the testkit backend AbstractRunner that wraps connection/timeout errors in Neo4jException when building DriverErrorResponse.
1 parent 23ca0b5 commit abfe5a8

21 files changed

Lines changed: 695 additions & 87 deletions

.github/workflows/testkit.yml

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

1414
steps:
1515
- uses: actions/checkout@v4
16+
with:
17+
submodules: recursive
1618

1719
- name: Restore Neo4j Image Cache if it exists
1820
id: cache-docker-neo4j

.gitmodules

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
[submodule "testkit"]
22
path = testkit
3-
url = https://github.com/neo4j-drivers/testkit.git
4-
branch = 5.0
5-
3+
url = git@github.com:nagels-tech/testkit.git

src/Bolt/BoltConnection.php

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use Bolt\enum\ServerState;
1717
use Bolt\enum\Signature;
18+
use Bolt\error\ConnectException as BoltConnectException;
1819
use Bolt\protocol\Response;
1920
use Bolt\protocol\V4_4;
2021
use Bolt\protocol\V5;
@@ -315,6 +316,9 @@ public function __destruct()
315316

316317
public function close(): void
317318
{
319+
// Graceful cleanup: GOODBYE/DISCARD may fail if connection already broken.
320+
// Only catch network/connection failures - if connection is broken we can't send anyway.
321+
// Other exceptions (Neo4jException, TypeError, etc.) should propagate.
318322
try {
319323
if ($this->isOpen()) {
320324
if ($this->isStreaming()) {
@@ -326,10 +330,29 @@ public function close(): void
326330

327331
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;
328332
}
329-
} catch (Throwable) {
333+
} catch (BoltConnectException $e) {
334+
$this->logger?->log(LogLevel::WARNING, 'Failed to close connection gracefully', [
335+
'exception' => $e->getMessage(),
336+
]);
330337
}
331338
}
332339

340+
/**
341+
* Invalidates the connection without sending GOODBYE message.
342+
*
343+
* This method closes the Bolt protocol and socket connection WITHOUT
344+
* sending a GOODBYE message, which is essential when handling timeout
345+
* exceptions or when the connection is already broken. Sending GOODBYE
346+
* on a broken connection can interfere with the server's expected
347+
* message sequence.
348+
*/
349+
public function invalidate(): void
350+
{
351+
$this->subscribedResults = [];
352+
$this->connection->disconnect();
353+
unset($this->boltProtocol);
354+
}
355+
333356
private function buildRunExtra(?string $database, ?float $timeout, ?BookmarkHolder $holder, ?AccessMode $mode, ?iterable $metadata): array
334357
{
335358
$extra = [];
@@ -416,31 +439,31 @@ public function assertNoFailure(Response $response): void
416439

417440
/**
418441
* Discard unconsumed results - sends DISCARD to server for each subscribed result.
442+
* Try-catch prevents DISCARD failures from breaking cleanup chain in Session.close().
419443
*/
420444
public function discardUnconsumedResults(): void
421445
{
422-
if (!in_array($this->protocol()->serverState, [ServerState::STREAMING, ServerState::TX_STREAMING], true)) {
423-
return;
424-
}
425-
426-
$this->logger?->log(LogLevel::DEBUG, 'Discarding unconsumed results');
427-
428-
$this->subscribedResults = array_values(array_filter(
429-
$this->subscribedResults,
430-
static fn (WeakReference $ref): bool => $ref->get() !== null
431-
));
432-
433-
if (!empty($this->subscribedResults)) {
434-
try {
435-
$this->discard(null);
436-
$this->logger?->log(LogLevel::DEBUG, 'Sent DISCARD ALL for unconsumed results');
437-
} catch (Throwable $e) {
438-
$this->logger?->log(LogLevel::ERROR, 'Failed to discard results', [
439-
'exception' => $e->getMessage(),
440-
]);
446+
if ($this->isOpen() && in_array($this->protocol()->serverState, [ServerState::STREAMING, ServerState::TX_STREAMING], true)) {
447+
$this->logger?->log(LogLevel::DEBUG, 'Discarding unconsumed results');
448+
449+
$this->subscribedResults = array_values(array_filter(
450+
$this->subscribedResults,
451+
static fn (WeakReference $ref): bool => $ref->get() !== null
452+
));
453+
454+
if (!empty($this->subscribedResults)) {
455+
try {
456+
$this->discard(null);
457+
$this->logger?->log(LogLevel::DEBUG, 'Sent DISCARD ALL for unconsumed results');
458+
} catch (BoltConnectException $e) {
459+
// Connection already broken - can't send DISCARD. Log and continue cleanup.
460+
$this->logger?->log(LogLevel::ERROR, 'Failed to discard results', [
461+
'exception' => $e->getMessage(),
462+
]);
463+
}
441464
}
442-
}
443465

444-
$this->subscribedResults = [];
466+
$this->subscribedResults = [];
467+
}
445468
}
446469
}

src/Bolt/BoltResult.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
namespace Laudis\Neo4j\Bolt;
1515

1616
use function array_splice;
17+
18+
use Bolt\error\ConnectException as BoltConnectException;
19+
1720
use function count;
1821

1922
use Generator;
@@ -100,7 +103,14 @@ public function consume(): array
100103

101104
private function fetchResults(): void
102105
{
103-
$meta = $this->connection->pull($this->qid, $this->fetchSize);
106+
try {
107+
$meta = $this->connection->pull($this->qid, $this->fetchSize);
108+
} catch (BoltConnectException $e) {
109+
// Invalidate connection on socket/network errors so pool does not reuse it.
110+
// Rethrow as-is - Session retry logic inspects the actual exception via isConnectionError().
111+
$this->connection->invalidate();
112+
throw $e;
113+
}
104114

105115
/** @var list<list> $rows */
106116
$rows = array_splice($meta, 0, count($meta) - 1);
@@ -154,6 +164,13 @@ public function __destruct()
154164

155165
public function discard(): void
156166
{
157-
$this->connection->discard($this->qid === -1 ? null : $this->qid);
167+
try {
168+
$this->connection->discard($this->qid === -1 ? null : $this->qid);
169+
} catch (BoltConnectException $e) {
170+
// Connection already broken if DISCARD fails. Invalidate to prevent pool from reusing it.
171+
// Don't rethrow: this is called from __destruct() where exceptions don't propagate properly.
172+
// Connection will be detected as broken on next operation when pool tries to reuse it.
173+
$this->connection->invalidate();
174+
}
158175
}
159176
}

src/Bolt/BoltUnmanagedTransaction.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace Laudis\Neo4j\Bolt;
1515

1616
use Bolt\enum\ServerState;
17+
use Laudis\Neo4j\Contracts\ConnectionPoolInterface;
1718
use Laudis\Neo4j\Contracts\UnmanagedTransactionInterface;
1819
use Laudis\Neo4j\Databags\BookmarkHolder;
1920
use Laudis\Neo4j\Databags\SessionConfiguration;
@@ -53,6 +54,7 @@ public function __construct(
5354
private readonly BookmarkHolder $bookmarkHolder,
5455
private readonly BoltMessageFactory $messageFactory,
5556
private readonly bool $isInstantTransaction,
57+
private readonly ?ConnectionPoolInterface $pool = null,
5658
) {
5759
}
5860

@@ -149,11 +151,14 @@ public function runStatement(Statement $statement): SummarizedResult
149151
$this->database,
150152
$this->tsxConfig->getTimeout(),
151153
$this->isInstantTransaction ? $this->bookmarkHolder : null, // let the begin transaction pass the bookmarks if it is a managed transaction
152-
$this->isInstantTransaction ? $this->config->getAccessMode() : null, // let the begin transaction decide if it is a managed transaction
154+
null, // mode is never sent in RUN messages - it comes from session configuration
153155
$this->tsxConfig->getMetaData()
154156
);
155157
} catch (Throwable $e) {
156158
$this->state = TransactionState::TERMINATED;
159+
if ($this->pool !== null) {
160+
$this->pool->release($this->connection);
161+
}
157162
throw $e;
158163
}
159164
$run = microtime(true);

0 commit comments

Comments
 (0)