Fix logservice client fallback from slow replica addresses#25334
Conversation
(cherry picked from commit e21b43d)
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
XuPeng-SH
left a comment
There was a problem hiding this comment.
I think this needs another pass before merge. The direction is right, but the current implementation introduces new failure modes in the exact connection-fallback path this PR is trying to harden.
Findings:
-
pkg/logservice/client.go:572/pkg/logservice/client.go:581leaks non-winning successful clients.resultsis buffered tolen(addresses), so after the first success is selected atpkg/logservice/client.go:619,cancel()is called andwg.Wait()waits for losers, but a loser goroutine can still take the always-readyresults <- result{client: c}branch instead of thectx.Done()branch. That client is then left inresultsand never closed. This can happen in normal multi-replica cases when fallback launches a second reachable address and both connect successfully. Please either make loser-success send non-buffered/cancel-aware, or drainresultsafterwg.Wait()and close every unreturned client. Add a regression test where two reachable attempts both succeed after fallback has launched, and assert the non-winning client is closed/no goroutine or fd leak remains. -
pkg/logservice/client.go:476builds a leader-first list for discovery/reverse-proxy, butpkg/logservice/client.go:498now shuffles that list before dialing. That regresses the intended and previous discovery behavior: the leader returned byGetShardInfoshould be tried first, with other replicas as fallback. This is also explicitly part of issue #25274 expected behavior. Keep shuffle for configuredServiceAddresses, but haveconnectToLogServiceByReverseProxycall the ordered/staggered dialer directly, or pass an explicitshuffle bool. Please add coverage that discovery preserves leader-first ordering. -
pkg/logservice/client.go:524changes the production logservice client backend read timeout from0to10sby passingtime.Second*10intogetRPCClient, which is applied atpkg/logservice/client.go:905. This is not just a per-address connect-attempt limit; it changes the read loop behavior for the returned client and can force idle clients or long in-flight operations to reset/fail after 10s. The per-attempt context already bounds the CONNECT handshake, so this should remain0unless there is a separate, tested reason to change client read timeout semantics.
Suggested shape:
- Keep
connectToLogServiceAddressbehavior identical to the old client except for the per-attempt context. - Separate address ordering from dial orchestration: direct configured addresses may be shuffled, discovery addresses must preserve leader-first.
- Ensure every successful but non-returned client is closed deterministically.
- Add unhappy-path tests for all-addresses-fail, parent context cancellation, multiple-success loser cleanup, and discovery leader-first ordering.
Local validation I ran on this head:
go test ./pkg/logservice -run TestClientCreationFallsBackFromUnreachableReplicaAddress -count=1go test ./pkg/logservice -run TestClientCreationFallsBackFromUnreachableReplicaAddress -count=10go test ./pkg/logservice -run "TestClientCanBeConnectedByReverseProxy|TestClientCreationFallsBackFromUnreachableReplicaAddress" -count=1- same targeted tests with
-race go test ./pkg/logservice -count=1gofmt -d pkg/logservice/client.go pkg/logservice/client_test.gogit diff --check origin/3.0-dev...HEAD
|
Reposting the change-request findings here for visibility. I already submitted a GitHub review with Blocking findings:
Suggested shape: keep |
LeftHandCold
left a comment
There was a problem hiding this comment.
I confirmed the existing change-request findings on the current head, so I cannot approve this as-is.
Blocking points:
- Non-winning successful clients can still leak. A successful loser can enqueue
result{client: c}into the bufferedresultschannel after cancellation, and the winner path returns afterwg.Wait()without draining and closing that client. - Discovery/reverse-proxy addresses are built leader-first, but the new
connectToLogServicenow shuffles all inputs. On3.0-dev, the old code effectively preserved leader-first for discovery becauselen(cfg.ServiceAddresses)is zero in that path. connectToLogServiceAddresschanges the returned client's backend read timeout from the old0semantics to10s. The per-attempt context should bound connect/handshake without changing the long-lived client's read-timeout behavior.
Please fix these paths and add coverage for multiple successful attempts, discovery leader-first ordering, all-addresses-fail, and parent-context cancellation.
- Restore getRPCClient read timeout to 0 (per-attempt ctx already bounds the connection handshake; read timeout can reset idle/in-flight clients) - Preserve leader-first ordering for discovery/reverse-proxy by extracting shardInfoReplicaAddresses and bypassing the shuffle in connectToLogService - Add closeUnusedConnectResultClients to drain and close all non-winning successful clients after winner selection - Add regression tests: leader-first ordering, all-addresses-fail, parent context cancellation, multiple-success loser cleanup, and fallback timer Co-Authored-By: Claude <noreply@anthropic.com>
XuPeng-SH
left a comment
There was a problem hiding this comment.
Re-reviewed the latest head f0b6d82ad14cf6c83a0ee756c57065a8fa12c7d2.
The previous three blocking items look mostly addressed in this revision:
- successful loser clients are now closed after
cancel()/wg.Wait()/drain; - discovery/reverse-proxy addresses now preserve leader-first ordering instead of being shuffled;
- the production RPC backend read timeout is back to the previous
0behavior.
I still need to request changes because the new concurrent fallback path can return the wrong terminal error when an earlier address has failed but the parent context later expires before the whole search finishes.
In connectToLogServiceAddresses, the <-ctx.Done() branch currently does this:
cancel()
wg.Wait()
closeUnusedConnectResultClients(results)
if lastErr != nil {
return nil, lastErr
}
return nil, ctx.Err()That means a mixed unhappy path such as fast stale address failure -> next replica hangs -> caller deadline fires returns the stale address error instead of context.DeadlineExceeded. This is not just error cosmetics: the PR is specifically fixing stale/slow logservice replica fallback, and callers/diagnostics lose the real terminal cause when their connect deadline is reached before all candidates complete.
I verified this locally with a temporary regression test using connectToLogServiceAddressFn: first address returns a sentinel error immediately, second waits for ctx.Done() and then returns. Expected context.DeadlineExceeded; actual error chain was only "first failed".
Suggested fix:
- In the
<-ctx.Done()branch, after cancel/wait/drain, returnctx.Err()(or the attached context cause if this codebase wants to preserve causes), notlastErr. - Keep
lastErronly for the normal path where all launched attempts have completed and all failed. - Consider pre-checking
ctx.Err()before launching the first/next attempt so an already-canceled parent does not start a dial and race back a non-context error. - Add regression coverage for:
- previous address failed, active address is still pending, parent deadline fires -> returns
context.DeadlineExceeded; - already-canceled parent context -> returns
context.Canceledand does not mask it with a dial error.
- previous address failed, active address is still pending, parent deadline fires -> returns
Local verification performed:
- New PR-targeted tests:
go test ./pkg/logservice -run 'TestClientCreationFallsBackFromUnreachableReplicaAddress|TestShardInfoReplicaAddressesKeepsLeaderFirst|TestConnectToLogServiceWithNoTargets|TestConnectToLogServiceAddressesReturnsLastError|TestConnectToLogServiceAddressesReturnsContextError|TestConnectToLogServiceAddressesClosesSuccessfulLosers|TestStopConnectFallbackTimer' -count=1passed with the required MO CGo flags. - Full package:
go test ./pkg/logservice -count=1 -timeout 120spassed locally with the same CGo setup.
XuPeng-SH
left a comment
There was a problem hiding this comment.
Re-reviewed latest head b4b67948314d7cc4258d5d5a7c232621c3e3d059.
The previous functional blockers are addressed: discovery keeps leader-first ordering, the returned RPC client read timeout is still 0, successful non-winning clients are drained/closed, and the mixed previous failure + parent deadline case now returns a context deadline error while preserving the previous failure in the message.
I still need one change before approving:
pkg/logservice/client.go:590adds a productioncontext.WithTimeoutfor the per-address attempt. This new timeout is now the primary unhappy path for stale/hanging replicas, so it should preserve MO's timeout cause semantics instead of returning a plaincontext.DeadlineExceeded. The repo convention/static check also explicitly pushes production code towardcontext.WithTimeoutCause(Makefile:951). Please usecontext.WithTimeoutCausehere, withmoerr.CauseNewLogServiceClientor a more specific new cause, and attach/preserve that attempt cause when sendinglastErrback to the coordinator. Add a regression test where all candidate replicas hang until the per-attempt timeout while the parent context is still alive, and assert the returned error remains deadline-classified and carries the MO cause/diagnostic context.
One non-blocking cleanup suggestion: connectContextError currently formats lastErr with %v, so errors.Is/Unwrap cannot see the last connect error. If callers ever need structured classification of that inner failure, prefer preserving both errors structurally instead of only embedding the text.
Local validation I ran on this head:
go test ./pkg/logservice -run 'TestClientCreationFallsBackFromUnreachableReplicaAddress|TestShardInfoReplicaAddressesKeepsLeaderFirst|TestConnectToLogServiceWithNoTargets|TestConnectToLogServiceAddressesReturnsLastError|TestConnectToLogServiceAddressesReturnsContextError|TestConnectToLogServiceAddressesReturnsContextErrorAfterPreviousFailure|TestConnectToLogServiceAddressesWithCanceledContextDoesNotDial|TestConnectToLogServiceAddressesClosesSuccessfulLosers|TestStopConnectFallbackTimer' -count=1 -timeout 120sgo test ./pkg/logservice -count=1 -timeout 180sgo test -race ./pkg/logservice -run 'TestClientCreationFallsBackFromUnreachableReplicaAddress|TestConnectToLogServiceAddressesReturnsContextErrorAfterPreviousFailure|TestConnectToLogServiceAddressesWithCanceledContextDoesNotDial|TestConnectToLogServiceAddressesClosesSuccessfulLosers' -count=1 -timeout 180sgo test ./pkg/logservice -run 'TestConnectToLogServiceAddressesReturnsContextErrorAfterPreviousFailure|TestConnectToLogServiceAddressesClosesSuccessfulLosers' -count=50 -timeout 180s
CI is still in progress, so this review is based on code inspection plus the local checks above.
XuPeng-SH
left a comment
There was a problem hiding this comment.
Requesting changes for one blocker:
pkg/logservice/client.go:590-596now wraps every candidate in a hard 5scontext.WithTimeoutCause(...)beforeconnectToLogServiceAddress.- That same context is reused by
CONNECT/CONNECT_RO, whose server-side paths keep retryingErrShardNotReady/ErrSystemBusyuntil the passed context expires (pkg/logservice/service.go:426-441,pkg/logservice/store.go:679-717). - TN callers still budget 30s for logservice client creation by default (
pkg/tnservice/cfg.go:48,109-110,pkg/tnservice/factory.go:105-116). So this changes behavior from “wait up to the callers connect timeout for a healthy shard to come up” to “kill each replica attempt after 5s”, which can reject healthy replicas during leader election / recovery.
Suggested fix: keep the staggered fallback, but do not put a fixed 5s deadline around the full connectReadWrite / connectReadOnly path. Either keep at least one viable attempt on the callers full budget, or restrict the short timeout to transport establishment / initial dial only.
XuPeng-SH
left a comment
There was a problem hiding this comment.
I did not find a remaining blocker on the current head.
The latest preserve caller connect timeout change fixes the previous regression cleanly: fallback still staggers new attempts behind a slow/hanging address, but each attempt now inherits the caller timeout instead of being hard-capped at 5s. That preserves the callers readiness budget while still allowing early fallback and loser cleanup/cancellation.
I also rechecked the unhappy-path surface on the current implementation: cancellation still terminates in-flight attempts, losing successful clients are closed, and the targeted logservice tests plus a package compile both pass on the PR head.
Merge Queue Status
This pull request spent 1 minute 41 seconds in the queue, with no time running CI. Waiting for
All conditions
ReasonPull request #25334 has been dequeued Queue conditions are not satisfied:
HintYou should look at the reason for the failure and decide if the pull request needs to be fixed or if you want to requeue it. Requeued — the merge queue status continues in this comment ↓. |
XuPeng-SH
left a comment
There was a problem hiding this comment.
Superseding my earlier approve: the latest head still has two blockers.
-
pkg/logservice/client.go:589,pkg/common/morpc/future.go:55-57- The new
preserve caller connect timeoutchange passes the caller context straight intoconnectToLogServiceAddressFn(ctx, ...). If the caller usescontext.Background()/WithCancel()without a deadline, the first successful TCP connect now reaches morpc with a deadline-less context and panics inFuture.init(context deadline not set). Earlier revisions wrapped each attempt in a timeout, so this failure mode did not exist. - Suggested fix: preserve an attempt-scoped deadline when the caller context has none, or fail explicitly before dialing instead of letting morpc panic.
- The new
-
pkg/logservice/client.go:632-653,pkg/common/morpc/backend.go:872-920- On first success or on caller cancellation,
connectToLogServiceAddressesstill doescancel(); wg.Wait(); ...before returning. But a launched loser can still be stuck in morpc backend creation, where the initialrb.conn.Connect(..., rb.options.connectTimeout)does not observe the caller context. So a blackholed loser dial can still delay both success return and cancellation return until that internal connect timeout expires. - Suggested fix: make backend creation/connect context-aware, or stop waiting on uncancelable loser dials before returning the winner / cancellation.
- On first success or on caller cancellation,
XuPeng-SH
left a comment
There was a problem hiding this comment.
Reviewed latest head 5fe0ab4.
The previous blockers look closed now:
- caller contexts without deadlines get an attempt-scoped timeout, so morpc Future.init will not panic on deadline-less contexts;
- caller contexts with deadlines preserve the caller deadline instead of forcing a shorter per-attempt timeout;
- winner and cancellation paths no longer wait synchronously for loser dials that may be inside backend connect;
- buffered successful loser clients are drained and closed asynchronously;
- reverse-proxy discovery keeps leader-first ordering, while explicit target dialing still shuffles.
Unhappy-path coverage is now reasonable for this change: empty targets, canceled context before dial, previous failure plus caller timeout, deadline-less caller context, winner-with-slow-loser, cancel-with-slow-loser, and successful loser cleanup.
Verified:
- go test ./pkg/logservice targeted fallback tests
- go test -race ./pkg/logservice targeted async cleanup tests
- go test ./pkg/logservice
- GitHub CI for 3.0 is green
Merge Queue Status
This pull request spent 26 seconds in the queue, including 2 seconds running CI. Required conditions to merge
|
What type of PR is this?
Which issue(s) this PR fixes:
issue #25274 #25277
What this PR does / why we need it:
This PR improves logservice client creation when candidate replica addresses include stale or unreachable endpoints.
Previously, connectToLogService tried replica addresses sequentially. If one address accepted a TCP connection but did not respond, client creation could wait until the outer context deadline before trying later
candidates.
Changes:
This reduces the impact of stale or slow replica addresses returned by discovery/gossip without changing HAKeeper or gossip behavior.