-
Notifications
You must be signed in to change notification settings - Fork 29.4k
[SPARK-59142][CORE] Verify streamId when matching stream fetch responses to callbacks in TransportResponseHandler #58440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
c96639b
ec56337
53ba42f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -235,6 +235,9 @@ public void handle(ResponseMessage message) throws Exception { | |
| } else if (message instanceof StreamResponse resp) { | ||
| Pair<String, StreamCallback> entry = streamCallbacks.poll(); | ||
| if (entry != null) { | ||
| // Guard against a desynced callback queue before using the polled callback. Under correct | ||
| // operation this is always a no-op; see verifyStreamCallbackMatches. | ||
| verifyStreamCallbackMatches(entry, resp.streamId, "response"); | ||
| StreamCallback callback = entry.getRight(); | ||
| if (resp.byteCount > 0) { | ||
| StreamInterceptor<ResponseMessage> interceptor = new StreamInterceptor<>( | ||
|
|
@@ -269,6 +272,9 @@ public void handle(ResponseMessage message) throws Exception { | |
| } else if (message instanceof StreamFailure resp) { | ||
| Pair<String, StreamCallback> entry = streamCallbacks.poll(); | ||
| if (entry != null) { | ||
| // Same guard as the StreamResponse branch: verify the polled callback is the one this | ||
| // failure is for before routing it. Under correct operation this is always a no-op. | ||
| verifyStreamCallbackMatches(entry, resp.streamId, "failure"); | ||
| StreamCallback callback = entry.getRight(); | ||
| try { | ||
| callback.onFailure(resp.streamId, new RuntimeException(resp.error)); | ||
|
|
@@ -284,6 +290,55 @@ public void handle(ResponseMessage message) throws Exception { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Verifies that the callback polled from the head of the FIFO {@link #streamCallbacks} queue is | ||
| * the one this stream response/failure is for, by comparing the callback's registered streamId | ||
| * with the streamId carried by the response. | ||
| * | ||
| * <p>Under correct operation this equality always holds and the method is a no-op: responses to | ||
| * {@code StreamRequest}s arrive on a single connection in the order the client sent them (see | ||
| * SPARK-11265), and the client registers each callback under the exact streamId it requested, so | ||
| * the head of the queue always corresponds to the next response. A mismatch is therefore not | ||
| * reachable by any normal client/server interaction; it could only be produced by memory or | ||
| * hardware corruption (e.g. a bit flip in the streamId or a corrupted queue). This is a defensive | ||
| * check that turns such corruption -- which would otherwise silently deliver the wrong block's | ||
| * bytes to a reader -- into a loud, retriable failure. | ||
| * | ||
| * <p>On a mismatch it fails the polled callback under its own streamId (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 throws {@link IllegalStateException}, which propagates to Netty's | ||
| * {@code exceptionCaught} so the connection is torn down and its remaining outstanding requests | ||
| * are re-fetched in order on a fresh channel. | ||
| */ | ||
| private void verifyStreamCallbackMatches( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new method builds its log message with String.format(...) and passes the result to logger.error(msg) as a plain pre-formatted string. Every other logger.error/logger.warn call with dynamic arguments in this file (migrated under SPARK-48209) uses the structured MDC form: logger.error("... {} ...", MDC.of(LogKeys.STREAM_ID, ...), MDC.of(LogKeys.HOST_PORT, ...)). Both LogKeys.STREAM_ID and LogKeys.HOST_PORT already exist and are already imported in this file; the fix is mechanical.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, thanks. Fixed in 53ba42f: the desync |
||
| 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 guard 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 {}. This is unreachable under correct operation and may " | ||
| + "indicate memory or hardware corruption; 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. This is unreachable under correct operation and may " | ||
| + "indicate memory or hardware corruption; 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); | ||
| } | ||
|
|
||
| /** Returns total number of outstanding requests (fetch requests + rpcs) */ | ||
| public int numOutstandingRequests() { | ||
| return outstandingFetches.size() + outstandingRpcs.size() + streamCallbacks.size() + | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This detects callback-queue desynchronization, not general transport or data corruption. A payload bit flip leaves the stream ID unchanged and is therefore undetected. If SPARK-59142’s goal is corruption detection, should we instead verify an end-to-end block checksum (or rely on an authenticated transport such as TLS)? If the goal is only asserting the FIFO protocol invariant, the PR description, implementation, and tests should be narrowed accordingly.