ChuckLin2025 commented on code in PR #58440:
URL: https://github.com/apache/spark/pull/58440#discussion_r4118225618


##########
common/network-common/src/main/java/org/apache/spark/network/client/TransportResponseHandler.java:
##########
@@ -284,6 +290,52 @@ public void handle(ResponseMessage message) throws 
Exception {
     }
   }
 
+  /**
+   * Asserts the FIFO ordering invariant for stream responses: the streamId 
carried by this
+   * response/failure must equal the streamId of the callback at the head of 
the
+   * {@link #streamCallbacks} queue. Responses to {@code StreamRequest}s are 
matched to callbacks by
+   * {@code poll()} order (see SPARK-11265), which is correct only if the 
server answers requests in
+   * the order the client sent them; this verifies that invariant before the 
polled callback is
+   * used. The check cannot false-fire: the client registers each callback 
under the exact streamId
+   * it requested, so a matching response always finds its callback at the 
head of the queue.
+   *
+   * <p>If the invariant does not hold the callback queue is desynced -- 
delivering this response
+   * would route the wrong block's bytes to the callback. It fails the polled 
callback under its
+   * own streamId with an {@link IOException} (so its caller does not hang 
waiting for a response it
+   * will never correctly receive; {@code poll()} has already removed it from 
the queue, and block
+   * fetches retry an {@code IOException} via {@code RetryingBlockTransferor}) 
and throws
+   * {@link IllegalStateException}. The throw propagates to Netty's {@code 
exceptionCaught}, which
+   * closes the desynced connection and fails its remaining outstanding 
requests; those callers
+   * then apply their own retry behavior where they support it. This turns a 
silent wrong-block
+   * delivery into a loud failure.
+   */
+  private void verifyStreamCallbackMatches(
+      Pair<String, StreamCallback> entry, String responseStreamId, String 
kind) {
+    if (entry.getLeft().equals(responseStreamId)) {
+      return;
+    }
+    // Log at the detection site, in the structured MDC form used throughout 
this file, so this
+    // otherwise-silent condition is directly greppable ("desynced") for 
incidence analysis,
+    // independent of the generic connection-exception log the thrown 
IllegalStateException produces
+    // downstream.
+    logger.error("Stream callback queue desynced: received streamId {} does 
not match the head of "
+        + "the callback queue from {}; failing the connection to avoid 
delivering the wrong block.",
+      MDC.of(LogKeys.STREAM_ID, responseStreamId),
+      MDC.of(LogKeys.HOST_PORT, getRemoteAddress(channel)));
+    // Full detail (both streamIds) goes on the exception that fails the 
callback and tears down the
+    // connection.
+    String msg = String.format(
+      "Stream callback queue desynced: %s streamId %s does not match the head 
of the callback "
+        + "queue (streamId %s) from %s; failing the connection to avoid 
delivering the wrong "
+        + "block.", kind, responseStreamId, entry.getLeft(), 
getRemoteAddress(channel));
+    try {
+      entry.getRight().onFailure(entry.getLeft(), new IOException(msg));
+    } catch (IOException ioe) {
+      logger.warn("Error in stream failure handler.", ioe);
+    }
+    throw new IllegalStateException(msg);

Review Comment:
   Fixed in 660fe96. I now build a single `IOException desync = new 
IOException(msg)`, fail the polled callback with it, and `throw new 
IllegalStateException(msg, desync)` — so the throwable `exceptionCaught` 
forwards to the connection's other outstanding callbacks carries an 
`IOException` cause, and `RetryingBlockTransferor` retries a concurrent block 
fetch instead of failing it permanently. The teardown test now asserts the 
collateral fetch's failure carries an `IOException` cause.



##########
common/network-common/src/main/java/org/apache/spark/network/client/TransportResponseHandler.java:
##########
@@ -284,6 +290,52 @@ public void handle(ResponseMessage message) throws 
Exception {
     }
   }
 
+  /**
+   * Asserts the FIFO ordering invariant for stream responses: the streamId 
carried by this
+   * response/failure must equal the streamId of the callback at the head of 
the
+   * {@link #streamCallbacks} queue. Responses to {@code StreamRequest}s are 
matched to callbacks by
+   * {@code poll()} order (see SPARK-11265), which is correct only if the 
server answers requests in
+   * the order the client sent them; this verifies that invariant before the 
polled callback is
+   * used. The check cannot false-fire: the client registers each callback 
under the exact streamId
+   * it requested, so a matching response always finds its callback at the 
head of the queue.
+   *
+   * <p>If the invariant does not hold the callback queue is desynced -- 
delivering this response
+   * would route the wrong block's bytes to the callback. It fails the polled 
callback under its
+   * own streamId with an {@link IOException} (so its caller does not hang 
waiting for a response it
+   * will never correctly receive; {@code poll()} has already removed it from 
the queue, and block
+   * fetches retry an {@code IOException} via {@code RetryingBlockTransferor}) 
and throws
+   * {@link IllegalStateException}. The throw propagates to Netty's {@code 
exceptionCaught}, which
+   * closes the desynced connection and fails its remaining outstanding 
requests; those callers
+   * then apply their own retry behavior where they support it. This turns a 
silent wrong-block
+   * delivery into a loud failure.
+   */
+  private void verifyStreamCallbackMatches(
+      Pair<String, StreamCallback> entry, String responseStreamId, String 
kind) {
+    if (entry.getLeft().equals(responseStreamId)) {
+      return;
+    }
+    // Log at the detection site, in the structured MDC form used throughout 
this file, so this
+    // otherwise-silent condition is directly greppable ("desynced") for 
incidence analysis,
+    // independent of the generic connection-exception log the thrown 
IllegalStateException produces
+    // downstream.
+    logger.error("Stream callback queue desynced: received streamId {} does 
not match the head of "
+        + "the callback queue from {}; failing the connection to avoid 
delivering the wrong block.",
+      MDC.of(LogKeys.STREAM_ID, responseStreamId),
+      MDC.of(LogKeys.HOST_PORT, getRemoteAddress(channel)));
+    // Full detail (both streamIds) goes on the exception that fails the 
callback and tears down the
+    // connection.
+    String msg = String.format(
+      "Stream callback queue desynced: %s streamId %s does not match the head 
of the callback "
+        + "queue (streamId %s) from %s; failing the connection to avoid 
delivering the wrong "
+        + "block.", kind, responseStreamId, entry.getLeft(), 
getRemoteAddress(channel));
+    try {
+      entry.getRight().onFailure(entry.getLeft(), new IOException(msg));
+    } catch (IOException ioe) {

Review Comment:
   Fixed in 660fe96. `DownloadCallback.onFailure` now runs 
`failRemainingBlocks(...)` (and the temp-file delete) in a `finally`, so the 
block is always failed even if `channel.close()` throws — the callback has 
already been polled off the response handler's queue, so teardown can't 
re-notify it, and skipping the notification would strand the block. The cleanup 
error still propagates. Added 
`OneForOneBlockFetcherSuite.testFetchToDiskFailsBlockWhenChannelCloseThrowsOnFailure`
 covering a channel whose `close()` throws.



##########
common/network-common/src/test/java/org/apache/spark/network/TransportResponseHandlerSuite.java:
##########
@@ -196,6 +198,114 @@ public void 
failStreamCallbackWhenInstallingInterceptorFails() throws Exception
     assertEquals(0, handler.numOutstandingRequests());
   }
 
+  @Test
+  public void streamResponseWithMismatchedStreamIdThrows() throws Exception {
+    // The FIFO streamCallbacks queue matches responses to callbacks by poll() 
order, which is
+    // correct only if a response's streamId equals the head-of-queue 
callback's registered streamId
+    // (the server answers StreamRequests in send order; SPARK-11265). This 
asserts that invariant:
+    // a StreamResponse whose streamId does not match means the queue is 
desynced, and delivering it
+    // would feed the wrong block's bytes to the callback. The check must 
throw instead
+    // (SPARK-59142); throwing propagates to Netty's exceptionCaught, which 
closes the desynced
+    // connection and fails its remaining outstanding requests.
+    // A mismatch is simulated by registering "stream-A" and handling a 
response for "stream-B".
+    Channel c = new LocalChannel();
+    c.pipeline().addLast(TransportFrameDecoder.HANDLER_NAME, new 
TransportFrameDecoder());
+    TransportResponseHandler handler = new TransportResponseHandler(c);
+
+    StreamCallback cb = mock(StreamCallback.class);
+    handler.addStreamCallback("stream-A", cb);
+
+    // A response for a different streamId arrives at the head of the FIFO 
queue.
+    StreamResponse mismatched = new StreamResponse("stream-B", 1234L, null);
+    IllegalStateException e = assertThrows(IllegalStateException.class,
+      () -> handler.handle(mismatched));
+    assertTrue(e.getMessage().contains("desynced"),
+      "expected a desync error, got: " + e.getMessage());
+    // The mismatched response must NOT have been delivered to the wrong 
callback as success...
+    verify(cb, never()).onComplete(any());
+    // ...and the polled callback is failed (with its OWN streamId) so its 
caller does not hang.
+    verify(cb, times(1)).onFailure(eq("stream-A"), isA(IOException.class));
+  }
+
+  @Test
+  public void streamFailureWithMismatchedStreamIdThrows() throws Exception {
+    Channel c = new LocalChannel();
+    c.pipeline().addLast(TransportFrameDecoder.HANDLER_NAME, new 
TransportFrameDecoder());
+    TransportResponseHandler handler = new TransportResponseHandler(c);
+
+    StreamCallback cb = mock(StreamCallback.class);
+    handler.addStreamCallback("stream-A", cb);
+
+    StreamFailure mismatched = new StreamFailure("stream-B", "uh-oh");
+    IllegalStateException e = assertThrows(IllegalStateException.class,
+      () -> handler.handle(mismatched));
+    assertTrue(e.getMessage().contains("desynced"),
+      "expected a desync error, got: " + e.getMessage());
+    // The failure must NOT be routed under the wrong (response) streamId; the 
polled callback is
+    // failed under its OWN streamId instead, so its caller does not hang.
+    verify(cb, never()).onFailure(eq("stream-B"), any());
+    verify(cb, times(1)).onFailure(eq("stream-A"), isA(IOException.class));
+  }
+
+  @Test
+  public void desyncTearsDownConnectionAndFailsAllOutstandingRequests() throws 
Exception {
+    // Upstream impact of the streamId assert. In production, throwing from 
handle() propagates to
+    // TransportChannelHandler.exceptionCaught, which calls 
responseHandler.exceptionCaught (failing
+    // EVERY outstanding request on the channel) and then ctx.close(). This 
test simulates that
+    // sequence and shows the meaning for callers: when a stream-callback 
desync is detected, the
+    // whole (poisoned) connection is torn down and ALL its in-flight requests 
-- the mismatched
+    // stream AND any innocent concurrent chunk-fetch sharing the channel -- 
are failed. None
+    // receive data. The polled stream callback is failed with an IOException 
(which block fetches
+    // retry via RetryingBlockTransferor); the remaining callbacks are failed 
on teardown and their
+    // callers apply their own retry behavior where they support it.
+    Channel c = new LocalChannel();
+    c.pipeline().addLast(TransportFrameDecoder.HANDLER_NAME, new 
TransportFrameDecoder());
+    TransportResponseHandler handler = new TransportResponseHandler(c);
+
+    // An innocent chunk fetch is in flight on the same connection.
+    StreamChunkId chunkId = new StreamChunkId(1, 0);
+    ChunkReceivedCallback chunkCb = mock(ChunkReceivedCallback.class);
+    handler.addFetchRequest(chunkId, chunkCb);
+    // ...and a stream fetch for "stream-A".
+    StreamCallback streamCb = mock(StreamCallback.class);
+    handler.addStreamCallback("stream-A", streamCb);
+    assertEquals(2, handler.numOutstandingRequests());
+
+    // A StreamResponse for the wrong streamId arrives -> handle() throws 
(desync detected).
+    // The desynced (polled) stream callback is failed inline so its caller 
does not hang.
+    IllegalStateException thrown = assertThrows(IllegalStateException.class,
+      () -> handler.handle(new StreamResponse("stream-B", 1234L, null)));
+    assertTrue(thrown.getMessage().contains("desynced"));
+    verify(streamCb, times(1)).onFailure(eq("stream-A"), any());
+    verify(streamCb, never()).onComplete(any());
+
+    // Netty then invokes exceptionCaught with the thrown cause; this is the 
teardown path that
+    // fails the connection's REMAINING outstanding requests (the innocent 
concurrent chunk fetch).
+    handler.exceptionCaught(thrown);

Review Comment:
   Fixed in 660fe96. Rewrote 
`desyncTearsDownConnectionAndFailsAllOutstandingRequests` to drive the 
mismatched `StreamResponse` through a real `TransportChannelHandler` on an 
`EmbeddedChannel` (rather than calling `responseHandler.exceptionCaught` by 
hand). It now asserts both that the connection's remaining outstanding request 
is failed — with an `IOException`-caused (retryable) throwable — and that the 
channel is closed (`!channel.isOpen()`).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to