Skip to content

Add local-node in-process dispatch for streaming transport (StreamTransportService)#22439

Draft
Bukhtawar wants to merge 1 commit into
opensearch-project:mainfrom
Bukhtawar:feature/analytics-local-fragment-transport
Draft

Add local-node in-process dispatch for streaming transport (StreamTransportService)#22439
Bukhtawar wants to merge 1 commit into
opensearch-project:mainfrom
Bukhtawar:feature/analytics-local-fragment-transport

Conversation

@Bukhtawar

@Bukhtawar Bukhtawar commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Description

Adds an in-process (local) streaming transport path centrally in StreamTransportService. When a
streaming 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 TransportService already provides for unary requests (localNodeConnectionsendLocalRequest
DirectResponseChannel). It intentionally shares that established contract: the request object is
handed 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-shipped
unary behavior. A non-streaming handler reaching this path hits the default handleStreamResponse
(throws UnsupportedOperationException), which is caught and routed to handleException.

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-process LocalStreamConnection for the local
node instead of a loopback wire connection. sendRequest on that connection runs
the registered handler against a LocalStreamChannel — a TransportChannel whose
sendResponseBatch/completeStream/sendResponse(Exception) place items on a bounded queue, and whose
responseStream() feeds the caller's handleStreamResponse(...) by draining that queue. Batches are
passed 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):

  1. 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), and
    isLocalNode() compares ephemeralId, so it never matches the cluster-state node here. Matching by
    node id also keeps this decision independent of isLocalNode()'s other role in connectToNode(),
    which suppresses establishing a loopback self-connection — the very connection non-local code paths
    still rely on.

  2. Dispatch is asynchronous. sendRequest forks the stream drain onto GENERIC and returns
    immediately, mirroring the async wire path where connection.sendRequest hands 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.

  3. handleStreamResponse is invoked on the plain TransportResponseHandler (it is a default method
    there); the StreamTransportResponseHandler marker sub-interface is not required, since
    streaming 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 on
queue.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
AutoCloseable contract, so off-heap Arrow buffers don't leak — the counterpart to
FlightServerChannel.releaseUnsentSource. ArrowBatchResponse now implements Closeable (delegating
to 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 and
released. If the consumer's handleStreamResponse throws, the channel is cancelled (draining/releasing
the remainder) and the failure is routed to handleException; producer-side failures enqueue a terminal
that surfaces as a TransportException on the next nextResponse().

Testing

Unit tests (LocalStreamChannelTests) — 8 tests covering in-order drain + end-of-stream, error
propagation 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/leak
contract: undelivered batches are close()d on cancel and on stream close, while a batch the consumer
already took is never closed by the channel (no double-free).

End-to-end on a single-node cluster with the full ClickBench hits dataset (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 getConnection fired the in-process path once per
    shard (5×) on the local path, 0× on the wire path.

  • Latency: interleaved A/B (wire/local alternating, 10 iterations, 0 mismatches):

    query (distinct keys) wire local speedup
    group-by UserID (17.7M) 2204 ms 1999 ms −9.3%
    group-by WatchID (100M) 3378 ms 3166 ms −6.3%
    group-by ClientIP (9.9M) 1479 ms 1411 ms −4.6%

    Pooled: −6.9% mean / −10% median. Distributions are non-overlapping per query (stdev 32–85 ms).

    An earlier measurement of the central path showed inconsistent, direction-flipping deltas
    (clientip +18%, watchid −8%). Root cause: the drain ran inline on the dispatch thread, serializing
    the 5 shards instead of overlapping them — a fixed per-shard penalty that swamped the serde savings
    on lighter queries. Forking the drain to GENERIC (design point 2 above) restored shard concurrency
    and the win now shows cleanly across all three queries.

Notes

  • The benefit is topology-dependent. Single-node "wire" traffic is already Flight loopback, so the win
    here (~5-9%) comes from skipping serialize/deserialize + a copy; a multi-node cluster (real gRPC +
    cross-node transfer) should show a larger benefit.
  • The path is always on for same-node streaming requests; there is no setting. Remote targets are
    unaffected.

@Bukhtawar Bukhtawar requested a review from a team as a code owner July 10, 2026 17:07
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 7d50a28)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Possible Deadlock

The producer executor selection avoids SAME by routing to GENERIC, but if the handler is registered on any single-threaded or small bounded executor and the consumer thread happens to be from that same pool, the producer can block on queue.put() while the consumer is blocked on queue.take() waiting for it (or sibling requests exhaust the pool). Only SAME is special-cased; other narrow executors could deadlock under load. Consider documenting the executor requirements or always dispatching the producer to GENERIC.

// Producer runs on the handler's executor (GENERIC when the handler registered SAME, since the
// consumer blocks draining nextResponse() and producing on the same thread would deadlock).
final Executor producerExecutor = ThreadPool.Names.SAME.equals(executor)
    ? getThreadPool().executor(ThreadPool.Names.GENERIC)
    : getThreadPool().executor(executor);
producerExecutor.execute(task);
Race Condition

cancel() sets consumerCancelled = true and drains the queue, but a concurrent producer in sendResponseBatch may pass the consumerCancelled check and then block/complete a queue.put() after cancel drained. That batch will sit in the queue and never be released, leaking its off-heap buffers. Consider re-draining after put if consumerCancelled becomes true, or using a lock/close flag to reject enqueues atomically.

public void sendResponseBatch(TransportResponse response) {
    if (consumerCancelled) {
        // Consumer stopped reading (early termination); drop the batch so it can be released.
        releaseIfPossible(response);
        return;
    }
    try {
        // Blocks when the queue is full — this is the producer-side backpressure.
        queue.put(response);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        onProducerError(new TransportException("interrupted while enqueuing local stream batch", e));
    }
}
Potential Leak

When sendResponse(Exception) or onProducerError is called with batches already queued behind the terminal, those batches never get released — nextResponse() sees the exception sentinel, sets consumerDrained = true, and returns without draining remaining items. The queue's undelivered AutoCloseable batches will leak until GC. Consider draining and releasing remaining batches when transitioning to a failure terminal, or on the consumer side after observing the error.

public void sendResponse(Exception exception) {
    if (terminated.compareAndSet(false, true)) {
        failure.set(exception);
        enqueueTerminal(exception);
    }
}

/** Producer-side failure (handler threw before/without a terminal). */
void onProducerError(Exception e) {
    if (terminated.compareAndSet(false, true)) {
        failure.set(e);
        enqueueTerminal(e);
    }
}

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 7d50a28

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Unblock producer blocked on full queue at cancel

After cancel, a producer blocked in queue.put() inside sendResponseBatch will not
observe consumerCancelled and can remain parked indefinitely if the queue was full
at cancel time. After setting the cancel flag and draining, wake any blocked
producer by enqueuing a terminal sentinel (or interrupting) so put() returns and
subsequent batches are dropped via the consumerCancelled check.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [428-436]

 void cancel(String reason, Throwable cause) {
     consumerCancelled = true;
     terminated.set(true);
     // Drain and release anything the producer already enqueued.
     Object item;
     while ((item = queue.poll()) != null) {
         releaseIfPossible(item);
     }
+    // Unblock any producer parked in queue.put() now that the consumer is gone.
+    queue.offer(endOfStream);
 }
Suggestion importance[1-10]: 8

__

Why: Valid concern: if cancel() is called while a producer is blocked in queue.put() on a full queue, draining and then not offering anything back leaves the producer parked. This is a real deadlock/leak risk worth addressing.

Medium
Close cancel/enqueue race to avoid buffer leak

There is a race between the producer checking consumerCancelled and calling
queue.put(): cancel can occur between these two steps, causing the batch to be
enqueued after the drain loop in cancel() already ran, leaking its buffers. After a
successful put(), re-check consumerCancelled and drain/release any items still on
the queue to close this window.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [354-367]

 @Override
 public void sendResponseBatch(TransportResponse response) {
     if (consumerCancelled) {
-        // Consumer stopped reading (early termination); drop the batch so it can be released.
         releaseIfPossible(response);
         return;
     }
     try {
-        // Blocks when the queue is full — this is the producer-side backpressure.
         queue.put(response);
+        if (consumerCancelled) {
+            Object item;
+            while ((item = queue.poll()) != null) {
+                releaseIfPossible(item);
+            }
+        }
     } catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         onProducerError(new TransportException("interrupted while enqueuing local stream batch", e));
     }
 }
Suggestion importance[1-10]: 6

__

Why: Identifies a genuine race between consumerCancelled check and queue.put() that could leave a batch on the queue after cancel drained it, leaking buffers. The proposed re-check narrows the window.

Low
General
Preserve caller-provided node in local connection

Passing localNode (the stream transport's own local node with a different
ephemeralId) as the connection's node causes getNode() to return a different
DiscoveryNode instance than the caller requested, which can confuse downstream code
that compares nodes by identity/ephemeralId. Return the caller-provided node from
the connection instead so connection.getNode() matches the requested node.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [145-147]

 if (localNode != null && node != null && localNode.getId().equals(node.getId())) {
-    return new LocalStreamConnection(localNode);
+    return new LocalStreamConnection(node);
 }
Suggestion importance[1-10]: 6

__

Why: Reasonable point: getNode() returning the stream transport's own localNode (with a different ephemeralId) rather than the caller-requested node can confuse identity-based comparisons downstream.

Low
Harden terminal enqueue against races and data loss

If another thread races and drains the queue between offer and poll, poll may return
null and this loop spins without releasing anything; also, releasing
already-enqueued batches here (when the consumer has not cancelled) discards valid
data the consumer would otherwise still receive. Guard against a null poll result
and, when not cancelled, avoid destroying valid pre-terminal batches — for
terminals, prefer waiting with put or only drop when cancelled.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [399-405]

 private void enqueueTerminal(Object terminal) {
-    // Terminal must not be lost even if the queue is full: clear consumer-abandoned items.
+    // Terminal must not be lost even if the queue is full.
     while (queue.offer(terminal) == false) {
         Object dropped = queue.poll();
-        releaseIfPossible(dropped);
+        if (dropped == null) {
+            continue;
+        }
+        if (consumerCancelled) {
+            releaseIfPossible(dropped);
+        } else {
+            // Preserve valid batches for the consumer; release only if we must drop.
+            releaseIfPossible(dropped);
+        }
     }
 }
Suggestion importance[1-10]: 2

__

Why: The improved code is essentially equivalent to the existing behavior (both branches call releaseIfPossible), and the null-poll guard is minor since only one producer exists. The concern about data loss is not meaningfully addressed by the suggestion.

Low

Previous suggestions

Suggestions up to commit d08c7b5
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix cancel/enqueue race that leaks batches

There is a race between sendResponseBatch reading consumerCancelled and cancel()
setting it and draining the queue. A batch enqueued after the drain but with
consumerCancelled=false observed just before cancel() sets it will remain in the
queue and leak. Re-check consumerCancelled after put and drain/release if it
flipped, so post-cancel enqueues never leak.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [353-366]

 @Override
 public void sendResponseBatch(TransportResponse response) {
     if (consumerCancelled) {
-        // Consumer stopped reading (early termination); drop the batch so it can be released.
         releaseIfPossible(response);
         return;
     }
     try {
-        // Blocks when the queue is full — this is the producer-side backpressure.
         queue.put(response);
+        if (consumerCancelled) {
+            // Cancel raced with enqueue; drain to release anything we (or others) left behind.
+            Object item;
+            while ((item = queue.poll()) != null) {
+                releaseIfPossible(item);
+            }
+        }
     } catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         onProducerError(new TransportException("interrupted while enqueuing local stream batch", e));
     }
 }
Suggestion importance[1-10]: 7

__

Why: Valid race condition: a batch enqueued just before cancel() sets consumerCancelled and drains the queue would remain undelivered and leak its off-heap buffers. The fix is reasonable, though the window is narrow.

Medium
Release queued batches on error terminal

When the terminal item is an Exception, any batches still sitting in the queue
behind it are never drained and their off-heap buffers leak. Drain and release
remaining queued items before throwing so undelivered batches are freed on the error
path, matching the cancel path's behavior.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [451-454]

 if (item instanceof Exception e) {
     consumerDrained = true;
+    Object leftover;
+    while ((leftover = queue.poll()) != null) {
+        releaseIfPossible(leftover);
+    }
     throw new TransportException("local stream producer failed for action [" + action + "]", e);
 }
Suggestion importance[1-10]: 6

__

Why: Correctly identifies that on an error terminal, any batches queued before the terminal (via enqueueTerminal displacing them) could be lost. However, enqueueTerminal already drains via releaseIfPossible, so the leak scenario is limited to items enqueued between terminal placement and consumer observation.

Low
General
Return connection with caller's node identity

Using localNode (the stream transport's own local node with a different ephemeralId)
as the connection node can confuse downstream code that compares by
identity/ephemeralId. Return a connection wrapping the caller's node argument (which
is the cluster-state node identity the caller expects) to keep the node object
consistent with non-local paths.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [144-146]

 if (localNode != null && node != null && localNode.getId().equals(node.getId())) {
-    return new LocalStreamConnection(localNode);
+    return new LocalStreamConnection(node);
 }
Suggestion importance[1-10]: 5

__

Why: Reasonable point about node identity consistency with the caller's expectations, though the practical impact is unclear since the local connection is discarded after a single sendRequest.

Low
Suggestions up to commit f98cdd7
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix leak when cancelling with blocked producer

When cancel is called, if the producer is currently blocked in queue.put()
(backpressure), setting terminated and draining once will not unblock it — the
producer will resume, put a batch onto the now-empty queue, and no one will ever
drain it (leaking that batch and any subsequent ones). Also, if a producer has
already reached a terminal path, setting terminated=true here prevents
onProducerError/completeStream from enqueueing the terminal sentinel. Consider
looping the drain until the producer observes consumerCancelled and stops, or have
sendResponseBatch check consumerCancelled after queue.put returns and release the
just-enqueued item.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [444-452]

 void cancel(String reason, Throwable cause) {
     consumerCancelled = true;
     terminated.set(true);
-    // Drain and release anything the producer already enqueued.
+    // Drain repeatedly: a producer blocked in queue.put() will unblock, enqueue,
+    // and (seeing consumerCancelled) stop; we must release whatever it leaves behind.
     Object item;
-    while ((item = queue.poll()) != null) {
-        releaseIfPossible(item);
-    }
+    do {
+        while ((item = queue.poll()) != null) {
+            releaseIfPossible(item);
+        }
+    } while (queue.peek() != null);
 }
Suggestion importance[1-10]: 7

__

Why: Valid concern: a producer blocked in queue.put() may enqueue a batch after cancel drains once, leaking it. The suggested fix isn't fully robust (still racy), but flags a real issue in the backpressure/cancel interaction.

Medium
Close race between producer put and cancel

There is a race between sendResponseBatch and cancel: the producer may check
consumerCancelled (false), block in queue.put, then cancel runs and drains the queue
once — the producer wakes, puts the batch, and it is never released. Re-check
consumerCancelled after queue.put returns and release the just-enqueued item if the
consumer cancelled while we were blocked.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [373-386]

 @Override
 public void sendResponseBatch(TransportResponse response) {
     if (consumerCancelled) {
-        // Consumer stopped reading (early termination); drop the batch so it can be released.
         releaseIfPossible(response);
         return;
     }
     try {
-        // Blocks when the queue is full — this is the producer-side backpressure.
         queue.put(response);
     } catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         onProducerError(new TransportException("interrupted while enqueuing local stream batch", e));
+        return;
+    }
+    // Post-put re-check: consumer may have cancelled while we were blocked in put().
+    if (consumerCancelled) {
+        Object item;
+        while ((item = queue.poll()) != null) {
+            releaseIfPossible(item);
+        }
     }
 }
Suggestion importance[1-10]: 7

__

Why: Identifies a genuine race between sendResponseBatch blocking in put() and cancel() draining, which could leak the just-enqueued batch. The proposed post-put re-check is a reasonable mitigation.

Medium
General
Return caller-supplied node on local connection

Using localNode (the service's own node) for the returned connection means the
connection reports the local instance rather than the caller-requested node. Callers
may compare connection.getNode() to the node they asked for (e.g. in
routing/logging); returning the passed-in node preserves that identity while still
dispatching in-process.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [164-166]

 if (localStreamOptimizationEnabled && localNode != null && node != null && localNode.getId().equals(node.getId())) {
-    return new LocalStreamConnection(localNode);
+    return new LocalStreamConnection(node);
 }
Suggestion importance[1-10]: 5

__

Why: Reasonable minor improvement — passing the caller-requested node preserves identity for callers comparing connection.getNode(). Since the ids match, functional impact is minor.

Low
Avoid double terminal on single-response path

If sendResponseBatch fails silently (e.g. producer thread interrupted,
onProducerError transitions to terminated), the following completeStream becomes a
no-op and the response may still be delivered — but if the batch was released due to
cancel, completeStream still enqueues end-of-stream on an already-terminated channel
path. Consider guarding completeStream so it isn't called when a terminal has
already been recorded via error, to keep the terminal semantics clear.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [396-400]

 @Override
 public void sendResponse(TransportResponse response) {
     // Non-streaming single response: deliver as one batch then complete.
     sendResponseBatch(response);
-    completeStream();
+    if (!terminated.get()) {
+        completeStream();
+    }
 }
Suggestion importance[1-10]: 3

__

Why: The existing completeStream already uses terminated.compareAndSet so it's a no-op if already terminated; the suggested guard is redundant and offers little value.

Low
Suggestions up to commit 35ec9a9
CategorySuggestion                                                                                                                                    Impact
Possible issue
Unblock consumer waiting during cancel

cancel() sets terminated to true but does not enqueue a terminal sentinel, so a
consumer thread currently blocked in queue.take() (from a concurrent nextResponse()
call before cancel was invoked) will hang forever since the producer sees
terminated==true in onProducerError/completeStream and never enqueues a sentinel.
Enqueue a terminal to unblock any in-flight take(), and rely on
consumerCancelled/consumerDrained to make subsequent calls return null.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [433-441]

 void cancel(String reason, Throwable cause) {
     consumerCancelled = true;
-    terminated.set(true);
+    boolean firstTerminate = terminated.compareAndSet(false, true);
     // Drain and release anything the producer already enqueued.
     Object item;
     while ((item = queue.poll()) != null) {
         releaseIfPossible(item);
     }
+    if (firstTerminate) {
+        // Unblock any consumer currently parked in queue.take().
+        queue.offer(endOfStream);
+    }
 }
Suggestion importance[1-10]: 7

__

Why: Valid concern: a consumer parked in queue.take() when cancel() is invoked would not be unblocked since no terminal sentinel is enqueued, potentially causing a hang. Adding a terminal offer is a real robustness fix.

Medium
Fix cancel/enqueue race leaking batches

There is a race where the consumer may cancel after the consumerCancelled check but
before/after queue.put completes, leaving the batch enqueued and never released by
the consumer. After enqueuing, re-check consumerCancelled and drain/release the item
if the consumer has cancelled, to avoid leaking batches that may hold off-heap
resources.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [373-386]

 @Override
 public void sendResponseBatch(TransportResponse response) {
     if (consumerCancelled) {
-        // Consumer stopped reading (early termination); drop the batch so it can be released.
         releaseIfPossible(response);
         return;
     }
     try {
-        // Blocks when the queue is full — this is the producer-side backpressure.
         queue.put(response);
+        if (consumerCancelled) {
+            // Cancel raced with enqueue; drain the item we just added so it doesn't leak.
+            if (queue.remove(response)) {
+                releaseIfPossible(response);
+            }
+        }
     } catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         onProducerError(new TransportException("interrupted while enqueuing local stream batch", e));
     }
 }
Suggestion importance[1-10]: 4

__

Why: The race is theoretically possible, but the comment in releaseIfPossible notes that at this transport layer there is nothing meaningful to release (dropping the reference suffices), so the practical impact is minimal.

Low
General
Preserve caller node identity for local connection

Returning the cached localNode here means the connection reports the local node's
Version and identity even if the caller passed a DiscoveryNode with different
attributes/version for the same id. Use the passed-in node to preserve
caller-visible identity (matching how getConnection normally returns a connection to
the requested node).

server/src/main/java/org/opensearch/transport/StreamTransportService.java [164-166]

 if (localStreamOptimizationEnabled && localNode != null && node != null && localNode.getId().equals(node.getId())) {
-    return new LocalStreamConnection(localNode);
+    return new LocalStreamConnection(node);
 }
Suggestion importance[1-10]: 5

__

Why: Reasonable point about preserving caller-visible node identity/version, though impact is limited since it's the same node id; more a correctness-of-contract nuance than a bug.

Low
Avoid pattern-match instanceof for compatibility

Using a pattern-match instanceof with a binding variable requires Java 16+; if the
project's baseline is older this will fail to compile. Use a traditional instanceof
+ cast for portability, which also matches the style used elsewhere in the file.

server/src/main/java/org/opensearch/transport/StreamTransportService.java [460-463]

-if (item instanceof Exception e) {
+if (item instanceof Exception) {
     consumerDrained = true;
-    throw new TransportException("local stream producer failed for action [" + action + "]", e);
+    throw new TransportException("local stream producer failed for action [" + action + "]", (Exception) item);
 }
Suggestion importance[1-10]: 3

__

Why: OpenSearch's baseline is Java 21, so pattern-match instanceof is supported; the suggestion is largely a style preference and not a correctness issue.

Low
Suggestions up to commit 4a572f2
CategorySuggestion                                                                                                                                    Impact
Possible issue
Signal stream completion when no batches produced

When the producer completes with zero batches (no pendingRoot), the consumer never
receives an isLast=true signal, which can leave the downstream stream hanging. Emit
an empty terminal batch or otherwise signal completion to the listener when there is
no pending root; also, onComplete() without metrics never notifies the listener at
all, so the consumer will never know the stream ended.

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchTransportService.java [490-506]

 @Override
 public void onComplete() {
     if (pendingRoot != null) {
         deliver(pendingRoot, true);
         pendingRoot = null;
+    } else if (consumerStopped == false) {
+        listener.onStreamComplete(null);
     }
 }
 
 @Override
 public void onCompleteWithMetrics(byte[] metrics) {
     if (pendingRoot != null) {
         deliver(pendingRoot, true);
         pendingRoot = null;
     }
     if (consumerStopped == false) {
         listener.onStreamComplete(metrics);
     }
 }
Suggestion importance[1-10]: 8

__

Why: Valid concern: onComplete() never notifies the listener when there are no batches, potentially leaving the consumer stream hanging. This is a meaningful correctness issue in the new local dispatch path.

Medium
Handle unexpected task type safely

The cast to AnalyticsShardTask will throw ClassCastException if TaskManager.register
returns a different task type (e.g., a wrapped/cancellable task), and this failure
path is only partially handled — shardTask will still be null at the catch block.
Validate the type or assign before the cast, so the null check in the catch block
reliably unregisters any partially-registered task.

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchTransportService.java [538-543]

 AnalyticsShardTask shardTask = null;
 try {
     request.setParentTask(clusterService.localNode().getId(), parentTask.getId());
-    shardTask = (AnalyticsShardTask) transportService.getTaskManager().register("transport", actionName, request);
+    Task registered = transportService.getTaskManager().register("transport", actionName, request);
+    if (registered instanceof AnalyticsShardTask ast) {
+        shardTask = ast;
+    } else {
+        transportService.getTaskManager().unregister(registered);
+        throw new IllegalStateException("Expected AnalyticsShardTask, got " + registered.getClass());
+    }
     ShardId shardId = shardIdOf(request);
     IndexShard shard = indicesService.indexServiceSafe(shardId.getIndex()).getShard(shardId.id());
Suggestion importance[1-10]: 4

__

Why: The cast is likely safe given how the request's createTask is defined, but the defensive handling adds robustness. Moderate value as a hardening measure.

Low
General
Document invocation-level teardown timing risk

localPath calls TransferPair.transfer(), which moves buffer ownership to the
consumer allocator but leaves the source VectorSchemaRoot in place; source.close()
here will still run and is fine, but after wirePath the source is also still open.
Ensure source is always closed cleanly — however note that Level.Invocation teardown
is discouraged by JMH for sub-millisecond ops and can distort measurements; consider
using Level.Iteration with a pool of sources or documenting the potential timing
impact.

sandbox/plugins/analytics-engine/benchmarks/src/main/java/org/opensearch/analytics/benchmark/LocalTransportHandoffBenchmark.java [136-143]

 @TearDown(Level.Invocation)
 public void freeSource() {
-    // If the benchmark consumed/closed the source it is a no-op; otherwise release it.
+    // NOTE: Level.Invocation teardown may perturb timing for fast ops; acceptable here since
+    // handoff cost dominates. Source is always still open after both benchmark paths.
     if (source != null) {
         source.close();
         source = null;
     }
 }
Suggestion importance[1-10]: 2

__

Why: Primarily a documentation/comment suggestion for a benchmark file; the code change is essentially a comment addition with marginal impact.

Low

@Bukhtawar Bukhtawar marked this pull request as draft July 10, 2026 17:27
@Bukhtawar Bukhtawar changed the title [analytics-engine] Local in-process transport for same-node fragments [analytics-engine][PROTOTYPE] Local in-process transport for same-node fragments (should move to StreamTransportService) Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

❌ 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?

@Bukhtawar Bukhtawar force-pushed the feature/analytics-local-fragment-transport branch from 4a572f2 to 5543b51 Compare July 11, 2026 18:13
@Bukhtawar Bukhtawar changed the title [analytics-engine][PROTOTYPE] Local in-process transport for same-node fragments (should move to StreamTransportService) Add local-node in-process dispatch for streaming transport (StreamTransportService) Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit d08c7b5.

PathLineSeverityDescription
server/src/main/java/org/opensearch/transport/StreamTransportService.java164lowThe LocalStreamConnection bypasses serialization/deserialization for local-node requests, explicitly documented as skipping wire-level validation. The comment acknowledges 'there is no wire to validate, so any input constraints must live in handler logic.' This mirrors the existing unary local path behavior but means any transport-layer security checks (size limits, field stripping, format validation) that occur during serialization are skipped for local streaming requests. Not malicious — it is intentional and documented — but warrants review to confirm all handler-level validations are sufficient without the wire path's implicit constraints.

The table above displays the top 10 most important findings.

Total: 1 | Critical: 0 | High: 0 | Medium: 0 | Low: 1


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@Bukhtawar Bukhtawar force-pushed the feature/analytics-local-fragment-transport branch from 5543b51 to 35ec9a9 Compare July 11, 2026 18:22
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 35ec9a9

@github-actions

Copy link
Copy Markdown
Contributor

❌ 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?

@Bukhtawar Bukhtawar force-pushed the feature/analytics-local-fragment-transport branch from 35ec9a9 to f98cdd7 Compare July 11, 2026 18:56
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit f98cdd7

@Bukhtawar Bukhtawar force-pushed the feature/analytics-local-fragment-transport branch from f98cdd7 to d08c7b5 Compare July 11, 2026 19:07
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d08c7b5

@github-actions

Copy link
Copy Markdown
Contributor

❌ 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>
@Bukhtawar Bukhtawar force-pushed the feature/analytics-local-fragment-transport branch from d08c7b5 to 7d50a28 Compare July 11, 2026 23:14
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7d50a28

@github-actions

Copy link
Copy Markdown
Contributor

❌ 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant