Add local-node in-process dispatch for streaming transport (StreamTransportService)#22439
Conversation
PR Reviewer Guide 🔍(Review updated until commit 7d50a28)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 7d50a28 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit d08c7b5
Suggestions up to commit f98cdd7
Suggestions up to commit 35ec9a9
Suggestions up to commit 4a572f2
|
|
❌ Gradle check result for 4a572f2: Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
4a572f2 to
5543b51
Compare
PR Code Analyzer ❗AI-powered 'Code-Diff-Analyzer' found issues on commit d08c7b5.
The table above displays the top 10 most important findings. Pull Requests Author(s): Please update your Pull Request according to the report above. Repository Maintainer(s): You can Thanks. |
5543b51 to
35ec9a9
Compare
|
Persistent review updated to latest commit 35ec9a9 |
|
❌ Gradle check result for 35ec9a9: FAILURE Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
35ec9a9 to
f98cdd7
Compare
|
Persistent review updated to latest commit f98cdd7 |
f98cdd7 to
d08c7b5
Compare
|
Persistent review updated to latest commit d08c7b5 |
|
❌ Gradle check result for d08c7b5: FAILURE Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
Route same-node streaming requests through an in-process path in StreamTransportService: the registered handler runs locally and hands response batches to the caller by reference, skipping the loopback serialize -> transport -> deserialize round-trip for data that never leaves the JVM. This is done centrally so every streaming consumer benefits without a per-plugin local-dispatch shim; the analytics fragment/fetch dispatch picks it up through its existing getConnection/sendChildRequest calls with no plugin change. Validated against a single-node ClickBench run (group-by on UserID/WatchID/ClientIP, 99.9M rows) showing a ~5-9% latency win, matching an earlier plugin-level prototype. Key design points: - Local detection matches on the persistent node id, not isLocalNode(). The stream transport resolves its own local node via a separate LocalNodeFactory (different random ephemeralId), so isLocalNode() — which compares ephemeralId — never matches the cluster-state node. Node-id matching also keeps the decision independent of isLocalNode()'s role in connectToNode(), which suppresses the loopback self-connection the non-local path relies on. - Dispatch is asynchronous: sendRequest forks the stream drain onto GENERIC and returns immediately, mirroring the async wire path. Draining inline would block the caller's dispatch thread for the whole stream and serialize sibling per-shard requests that the wire path runs concurrently. - handleStreamResponse is invoked on the plain TransportResponseHandler (it is a default method there); the StreamTransportResponseHandler marker is not required, since streaming consumers such as the analytics fragment handler implement streaming on a plain TransportResponseHandler. Ownership/leak safety mirrors the wire path: batches flow through a bounded ArrayBlockingQueue (producer blocks when full, matching gRPC-readiness backpressure). The consumer owns and closes every batch it takes; batches left undelivered on cancel/close are released by the channel via the generic AutoCloseable contract, so off-heap Arrow buffers don't leak — the counterpart to FlightServerChannel.releaseUnsentSource. ArrowBatchResponse implements Closeable (delegating to root close) so the core transport can free these without depending on the Arrow types. The terminal state is sticky: once the consumer drains end-of-stream/error/cancel, nextResponse() returns null without re-blocking on queue.take(). This is the streaming analogue of the unary in-process path core TransportService already provides (localNodeConnection -> sendLocalRequest -> DirectResponseChannel) and shares that contract: the request is handed to the handler as-is (no wire to validate), and the per-request connection handle is never pooled (no-op close/addCloseListener, isClosed() == false). Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
d08c7b5 to
7d50a28
Compare
|
Persistent review updated to latest commit 7d50a28 |
|
❌ Gradle check result for 7d50a28: null Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
Description
Adds an in-process (local) streaming transport path centrally in
StreamTransportService. When astreaming request's target is the coordinator's own node, the registered handler is run in-process and
its response batches are handed to the caller by reference, skipping the loopback
serialize → gRPC frame → deserialize round-trip for data that never leaves the JVM. Remote targets are
unaffected — they take the existing wire path unchanged.
Note
Relationship to the core unary local path. This is the streaming analogue of the in-process path
core
TransportServicealready provides for unary requests (localNodeConnection→sendLocalRequest→
DirectResponseChannel). It intentionally shares that established contract: the request object ishanded to the handler as-is with no serialize/deserialize round-trip (there is no wire to validate, so
input constraints must live in handler logic — exactly as for the unary local path today), and the
connection is a per-request synthetic handle that is never pooled or reused (no-op
close()/addCloseListener,isClosed() == false). These are not new bypasses; they mirror the long-shippedunary behavior. A non-streaming handler reaching this path hits the default
handleStreamResponse(throws
UnsupportedOperationException), which is caught and routed tohandleException.Note
An earlier revision of this PR implemented the same optimization as a prototype inside the
analytics plugin (
AnalyticsSearchTransportService.dispatchLocal) to prove it out and benchmark it.This has now been moved to the transport layer as originally intended, so every streaming
consumer — analytics fragment/fetch and any future streaming action — gets it for free, with no
per-plugin shim. The analytics side collapses back to a plain
sendChildRequest.How it works
StreamTransportService.getConnection(node)returns an in-processLocalStreamConnectionfor the localnode instead of a loopback wire connection.
sendRequeston that connection runsthe registered handler against a
LocalStreamChannel— aTransportChannelwhosesendResponseBatch/completeStream/sendResponse(Exception)place items on a bounded queue, and whoseresponseStream()feeds the caller'shandleStreamResponse(...)by draining that queue. Batches arepassed by reference; ownership transfers to the consumer exactly as on the wire path (the consumer
closes each response it receives).
Three design points that were essential to get right (each caught and fixed during single-node
validation — see the root-cause notes below):
Local detection matches on the persistent node id, not
isLocalNode(). The stream transportresolves its own local node via a separate
LocalNodeFactory(different randomephemeralId), andisLocalNode()comparesephemeralId, so it never matches the cluster-state node here. Matching bynode id also keeps this decision independent of
isLocalNode()'s other role inconnectToNode(),which suppresses establishing a loopback self-connection — the very connection non-local code paths
still rely on.
Dispatch is asynchronous.
sendRequestforks the stream drain ontoGENERICand returnsimmediately, mirroring the async wire path where
connection.sendRequesthands off and returns.Draining inline would block the caller's dispatch thread for the whole stream and serialize
sibling per-shard requests that the wire path runs concurrently.
handleStreamResponseis invoked on the plainTransportResponseHandler(it is a default methodthere); the
StreamTransportResponseHandlermarker sub-interface is not required, sincestreaming consumers such as the analytics fragment handler implement streaming on a plain
TransportResponseHandler.Backpressure: batches flow through a bounded
ArrayBlockingQueue, so the producer blocks when full,mirroring the wire path's gRPC-readiness flow control. The terminal state is sticky — once the consumer
drains end-of-stream / error / cancel,
nextResponse()returns null without re-blocking onqueue.take().Cancellation / exception / leak safety. Ownership mirrors the wire path: the consumer owns and
closes every batch it takes from the queue. Batches left undelivered when the consumer cancels or
closes the stream (or that the producer sends after cancel) are released by the channel via the generic
AutoCloseablecontract, so off-heap Arrow buffers don't leak — the counterpart toFlightServerChannel.releaseUnsentSource.ArrowBatchResponsenow implementsCloseable(delegatingto its root close) so the core transport can free these without depending on the Arrow types. The queue
is the single ownership arbiter (
poll()removes exactly once), so a batch is never both delivered andreleased. If the consumer's
handleStreamResponsethrows, the channel is cancelled (draining/releasingthe remainder) and the failure is routed to
handleException; producer-side failures enqueue a terminalthat surfaces as a
TransportExceptionon the nextnextResponse().Testing
Unit tests (
LocalStreamChannelTests) — 8 tests covering in-order drain + end-of-stream, errorpropagation to the consumer, bounded-queue backpressure (producer blocks when full, unblocks on drain),
consumer cancel dropping batches, single
sendResponse→ one batch then end, and the ownership/leakcontract: undelivered batches are
close()d on cancel and on stream close, while a batch the consumeralready took is never closed by the channel (no double-free).
End-to-end on a single-node cluster with the full ClickBench
hitsdataset (99.9M rows, 5 shards).The optimization is now unconditional; these numbers were gathered with a temporary toggle (since
removed) so the local path could be A/B'd against the wire path on the same node:
Correctness: local path returns results identical to the wire path, verified per-query
(interleaved local vs wire).
Local dispatch confirmed engaging: instrumented
getConnectionfired the in-process path once pershard (5×) on the local path, 0× on the wire path.
Latency: interleaved A/B (wire/local alternating, 10 iterations, 0 mismatches):
Pooled: −6.9% mean / −10% median. Distributions are non-overlapping per query (stdev 32–85 ms).
Notes
here (~5-9%) comes from skipping serialize/deserialize + a copy; a multi-node cluster (real gRPC +
cross-node transfer) should show a larger benefit.
unaffected.