[CELEBORN-2413] Fail retriably instead of asserting when the C++ transport connection is closed - #3801
[CELEBORN-2413] Fail retriably instead of asserting when the C++ transport connection is closed#3801yugan95 wants to merge 2 commits into
Conversation
…sport connection is closed
There was a problem hiding this comment.
Pull request overview
This PR adjusts the C++ client’s network failure semantics so that attempts to send/fetch on a closed transport connection (and connection-establishment failures) surface as retriable CelebornRuntimeErrors instead of non-retriable assertion-style failures, aligning behavior with the Java client’s “fail outstanding requests on channel inactive” contract.
Changes:
- Convert “connection closed” conditions in
MessageDispatchersend/fetch paths fromCELEBORN_CHECKto ready futures / promise failures carrying retriableCelebornRuntimeErrors (including close-during-send race handling). - Make
TransportClientFactory::createClientconnect failures throw a retriableCelebornRuntimeErrorinstead ofCELEBORN_FAIL. - Add unit tests verifying post-close send/fetch and in-flight request failure behavior is retriable and immediately observable via ready futures.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| cpp/celeborn/network/TransportClient.cpp | Marks connect-establishment failures as retriable by throwing CelebornRuntimeError(isRetriable=true) instead of CELEBORN_FAIL. |
| cpp/celeborn/network/MessageDispatcher.cpp | Replaces hard closed-connection assertions with retriable promise/future failures and adds race handling to prevent hung futures. |
| cpp/celeborn/network/tests/MessageDispatcherTest.cpp | Adds tests asserting closed-connection send/fetch and close()-failed in-flight requests produce ready futures with retriable exceptions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| folly::exception_wrapper makeConnectionClosedException( | ||
| const std::string& detail) { | ||
| return folly::make_exception_wrapper<utils::CelebornRuntimeError>( | ||
| __FILE__, | ||
| static_cast<size_t>(__LINE__), | ||
| __FUNCTION__, | ||
| /*expression=*/"connection closed", | ||
| /*message=*/detail, | ||
| utils::error_source::kErrorSourceRuntime.c_str(), | ||
| utils::error_code::kInvalidState.c_str(), | ||
| /*isRetriable=*/true); | ||
| } |
There was a problem hiding this comment.
Valid — every failure reported the helper's own location, so all of them collapsed onto one line.
Fixed: the helper now takes file/line/function from the caller, and each failing site (operator(), sendFetchChunkRequest, failPendingRequest, failPendingFetch, cleanup()) passes its own __FILE__/__LINE__/__FUNCTION__.
There was a problem hiding this comment.
@yugan95, thanks for contribution. I have left some comments for this pull request. PTAL.
| @@ -178,7 +209,21 @@ folly::Future<std::unique_ptr<Message>> MessageDispatcher::operator()( | |||
|
|
|||
| this->pipeline_->write(std::move(toSendMsg)); | |||
There was a problem hiding this comment.
Please observe and handle the write future here. Wangle's AsyncSocketHandler::write returns an exceptional future immediately when the socket is no longer good, and its asynchronous writeErr also completes that future exceptionally. Those failures do not necessarily set this dispatcher's closed_ before the trailing check, so discarding the future can leave the registered request promise pending until its timeout—the hang this change is intended to avoid. Please attach an error continuation that removes and fails the corresponding registry entry retriably, and cover both RPC/push and fetch with a mock handler that returns a failed write future. Reference: https://github.com/facebook/wangle/blob/v2024.07.01.00/wangle/channel/AsyncSocketHandler.h#L95-L113
There was a problem hiding this comment.
You're right, and this is the exact mechanism the description cites as the Java analogue. wangle::AsyncSocketHandler::write returns an already-failed future when !socket_->good() ("socket is closed in write()", AsyncSocketHandler.h), and otherwise fails it later from AsyncTransport::WriteCallback::writeErr. Neither path necessarily flips closed_ before the trailing check, so discarding the future left the registered promise pending until the request timeout — the hang this PR is about.
Both send paths now attach .thenError, which removes the registry entry and fails it retriably: failPendingRequest(requestId, ...) for rpc/push and failPendingFetch(streamChunkSlice, ...) for fetch. Both are no-ops when the entry is already gone (fulfilled, cleaned up or interrupted).
The message is phrased "Failed to send request {}, errorMsg: {}" to match Java's StdChannelListener, so ShuffleClientImpl::getPushDataFailCause -> connectFail() classifies it as PUSH_DATA_CONNECTION_EXCEPTION_PRIMARY instead of the non-critical default.
Covered by two new tests using a MockHandler whose write() returns a failed future: sendRpcRequestFailedWriteFailsRequestRetriably and sendFetchChunkRequestFailedWriteFailsRetriably — each asserts the dispatcher is still available (only the write failed) and the future is ready with a retriable CelebornException.
| // rather than asserting, so the caller's retry/failover logic can recover. | ||
| if (closed_.load()) { | ||
| return folly::makeFuture<std::unique_ptr<Message>>( | ||
| makeConnectionClosedException(fmt::format( |
There was a problem hiding this comment.
This retriable classification is discarded at the next public API layer. TransportClient::sendRpcRequestSync catches the exception and rethrows via CELEBORN_FAIL (isRetriable=false), while the push and fetch thenError handlers convert it to std::runtime_error. Consequently callers cannot observe the isRetriable=true value asserted by these dispatcher-only tests. Please preserve the CelebornException classification through TransportClient and add API-level tests for the sync, push, and fetch paths.
There was a problem hiding this comment.
Confirmed — sendRpcRequestSync re-threw through CELEBORN_FAIL (hardcoded isRetriable=false) and the push/fetch thenError continuations flattened the cause into a plain std::runtime_error, so no caller could observe the flag.
TransportClient now preserves it:
sendRpcRequestSyncandfetchChunkAsync's catch usefailPreservingRetriable(...), which carries the cause'sisRetriable.- the push/fetch
thenErrorcontinuations usetoCallbackException(...), forwarding a retriableCelebornRuntimeErroras-is. - the synchronous push catch uses
wrapCallbackException(...), which keeps the flag while wrapping the contextual message.
A non-retriable cause still produces a byte-identical std::runtime_error(message), so getPushDataFailCause string matching is unchanged (there's a regression assertion for PUSH_DATA_FAIL_PARTITION_NOT_FOUND). The callback consumers only read what(), so forwarding the exception rather than a copy of its message is behaviour-neutral for them.
Added API-level tests in TransportClientTest.cpp with a MockDispatcher that returns a retriable failure: sendRpcRequestSyncPreservesRetriableFailure, pushDataAsyncPreservesRetriableFailure, fetchChunkAsyncPreservesRetriableFailure.
|
Thanks for the review @SteNicholas. All three comments are addressed and pushed — the exception helper now carries the caller's location, both send paths observe the write future and fail the pending request retriably, and |
What changes were proposed in this pull request?
When the C++ transport connection is already closed, or when the socket write itself fails,
MessageDispatchereither raises a non-retriable error or leaves the registered request promise pending until its timeout. Both send paths guard with a hardCELEBORN_CHECK(!closed_)and drop the write future:and
TransportClientFactory::createClientraises a non-retriableCELEBORN_FAILwhen a connection cannot be established. (CELEBORN_CHECK/CELEBORN_FAILthrow aCelebornRuntimeErrorwithisRetriable=false; they do not abort the process.)This PR:
MessageDispatcher::operator()andsendFetchChunkRequest: the leadingCELEBORN_CHECK(!closed_)becomes a fast path that returns a ready retriable future; the trailing check now fulfils the just-registered promise with a retriable exception (see the race note below) rather than asserting.wangle::AsyncSocketHandler::writereturns an already-failed future when!socket_->good()("socket is closed in write()"), and otherwise fails it later fromAsyncTransport::WriteCallback::writeErr; neither necessarily flipsclosed_first, so discarding the future left the registered promise pending until the request timeout. The error continuation now removes the registry entry and fails it retriably. This is the analogue of Java'sTransportClientStdChannelListener#operationComplete.MessageDispatcher::cleanup(): the two outstanding-request failures switch from a plainstd::runtime_errorto the same retriable exception, so every request failed on close carries consistent classification.TransportClient. PreviouslysendRpcRequestSyncre-threw viaCELEBORN_FAIL(hardcodedisRetriable=false) and the push/fetchthenErrorcontinuations flattened the cause into a plainstd::runtime_error, so the classification was discarded before any caller could observe it. The three paths now forward or re-wrap the cause keepingisRetriable. A non-retriable cause keeps byte-identicalstd::runtime_error(message)behaviour, soShuffleClientImpl::getPushDataFailCausestring matching is unaffected.TransportClientFactory::createClient: the connect failure throws a retriableCelebornRuntimeErrorinstead ofCELEBORN_FAIL.The write-failure message is phrased
"Failed to send request {}, errorMsg: {}", matching the Java listener's wording, soShuffleClientImpl::getPushDataFailCause→connectFail()classifies it asPUSH_DATA_CONNECTION_EXCEPTION_PRIMARYrather than as the non-critical default.Why are the changes needed?
Sending on a closed connection, or having a socket write fail, is a normal recoverable condition — the peer closed the socket because of a worker restart or an idle timeout — not an invariant violation. Raising a non-retriable error on that condition is wrong, and dropping the write future is worse: the promise stays pending until the request timeout, which is exactly the stall this change is meant to avoid.
This aligns the C++ dispatcher's connection-failure semantics with the Java client. In Java the request path never hard-fails on a dead channel:
TransportResponseHandler#channelInactivecallsfailOutstandingRequests(new IOException("Connection from ... closed")), invoking every outstanding fetch/rpc/push callback'sonFailure(cause). This is the analogue of the C++cleanup()and of the trailing-check path below.TransportClient#sendRpc/fetchChunk/pushDataattachStdChannelListener, whoseoperationCompletecloses the channel and callshandleFailure→ the callback'sonFailurewhen the write fails. The.thenErrorcontinuation on the write future is the wangle equivalent.In both cases Java fails the request rather than raising a non-retriable error, and
CelebornInputStream#createReaderWithRetry(and the push revive path) then retry or fail over to the replica.One honest scope note: there is no retriable flag on the Java exceptions, and no C++ path branches on
isRetriable()yet — both clients currently catch broadly and retry. What this PR fixes is that the classification was previously unobservable at the public API at all; a consumer that branches on it is follow-up work.Does the close-during-send race matter?
close()setsclosed_ = truebeforecleanup()locks the registry. Ifcleanup()runs between the leading fast-path check and the promise registration, the promise would be registered into an already-drained registry and never fulfilled — the future would hang until the request times out. The trailingclosed_.load()check re-inspects the registry after registration and fails the promise with a retriable exception if it is still there, so the caller sees a ready retriable error instead of a stall.closeFailsInFlightRequestsRetriablycovers the in-flight case.Does this PR resolve a correctness bug?
Shuffle output is unaffected. This is a robustness / failure-handling fix: a routine connection close or a failed write no longer raises a non-retriable error, and no longer leaves the request pending until its timeout; it surfaces as a retriable error the existing retry/failover paths can act on.
Does this PR introduce any user-facing change?
No config, API, or shuffle-behaviour change. A connection-closed or failed-write condition that previously failed non-retriably (or hung until timeout) now fails the affected request retriably.
How was this patch tested?
New unit tests in
cpp/celeborn/network/tests/MessageDispatcherTest.cpp:sendRpcRequestAfterCloseFailsRetriably— a send issued afterclose()returns a ready, retriable exception instead of tripping the assertion.sendFetchChunkRequestAfterCloseFailsRetriably— same for the fetch path.closeFailsInFlightRequestsRetriably—close()fails an already-registered in-flight request retriably rather than leaving its future pending.sendRpcRequestFailedWriteFailsRequestRetriably/sendFetchChunkRequestFailedWriteFailsRetriably— aMockHandlerwhosewrite()returns a failed future fails the request retriably while the dispatcher stays available, covering the rpc/push and fetch paths.New unit tests in
cpp/celeborn/network/tests/TransportClientTest.cpp— aMockDispatcherreturning a retriable failure, asserting the classification survives to the public API on the sync, push and fetch paths:sendRpcRequestSyncPreservesRetriableFailurepushDataAsyncPreservesRetriableFailurefetchChunkAsyncPreservesRetriableFailureThe C++ unit test suite passes locally on macOS/arm64 with no regressions, and is covered by the
Celeborn Cpp Integration Testworkflow (Run Unittests of Celeborn Cpp).