mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #3740] [Bug]: Store clients take ~2 minutes to reattach after a master failover, though promotion itself takes ~10s #951
Labels
No labels
ascend
auto-closed
bug
bug
CI/Build
Common
documentation
enhancement
feature request
good first issue
help wanted
high priority
MetaX
nightly-failure
pull-request
PyTorch Backend
question
RFC
Roadmap
stale
Store
Tests
Transfer Engine
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/Mooncake#951
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @gongwei-130 on GitHub (Aug 27, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/3740
Originally assigned to: @Icedcoco on GitHub.
Bug Report
On
0.3.13with-enable_ha=true -enable_oplog=true -ha_backend_type=etcd, master failoveritself works well: promotion is fast and no metadata is lost — this is a clear improvement
over
0.3.12.post1. But the pool is unreadable for roughly two minutes afterwards, because thestore clients do not reattach for that long. The master serves an intact 200-object index to
nobody.
Reproduced twice, with near-identical timing both times.
Setup
mooncake_master(primary + hot standby), 3mooncake_clientstore nodes contributing 200 GiB each (600 GiB pool)
docker.io/kvcacheai/mooncake:0.3.13-enable_ha=true -ha_backend_type=etcd -enable_oplog=true -etcd_endpoints=<etcd>:2379-master_server_address=etcd://<etcd>:2379 -protocol=rdma -global_segment_size=200GBkubectl delete pod <primary>What works
~4 s from pod deletion to promotion, ~10 s to serving,
lag=0throughout, and a verify runafter both failovers found 200/200 objects present and intact.
What does not
The new primary serves an index it has no clients for:
So the metadata survived and the data survived, but for ~2 minutes every read fails.
First cause: the view is published before the RPC port is bound
The new primary writes its
master_viewentry to etcd when it wins the election, then spends~5 s in warmup before it binds
:50051. Since #2484 made the client's view watch event-driven,clients now wake up inside that window and connect to a port nobody is listening on. From a
store client (a different run of the same test):
The new leader began serving 0.7 s later. This first failure looks like a straightforward
ordering problem: publish the view after the RPC port is accepting connections, not before.
Before #2484 the 200 ms/1 s polling cadence would usually have hidden it; making discovery fast
made the race reliable.
Second, and the part we cannot explain: recovery then takes ~120 s
A single lost race should cost a retry, not two minutes. Reading v0.3.13, every retry path
looks like it should recover in seconds:
Client::LeaderMonitorThreadMain(client_service.cpp:720) sleepskErrorRetryInterval= 1 s after a failedSwitchLeader, then loops. It does not need afresh watch event to retry:
EtcdLeaderCoordinator::WaitForViewChangere-reads the view atthe top of each iteration and returns
changed = truewhenever it differs from the versionthe client last committed (
etcd_leader_coordinator.cpp:434-440) — and a failedSwitchLeaderdeliberately does not commit one.max_ping_fail_count= 3 failures atfail_ping_interval_ms= 1000 (client_service.cpp:4726,4730), then remounts segmentswhen the master answers
NEED_REMOUNT.Either path should have the client back within single-digit seconds. We measured ~120 s, twice,
and could not determine which path stalls or why.
Clients: 0on the master for the wholewindow suggests pings are not landing rather than the switch never being retried, but we did
not confirm that.
Happy to run instrumented builds, raise client log verbosity, or test a patch — this is a test
cluster and the failover is a one-command reproduction.
Expected
Promotion is ~10 s. Clients should reattach on that order, not 12× later.
Suggested fixes
master_viewentry, so a client that reactsimmediately to the watch reaches a listening socket. This alone removes the trigger.
loses the race for any other reason (a slow node, a network blip) hits the same stall.
Related
turns this ordering window from unlikely into reliable.
the client side after a successful promotion — the metadata was fine in every run here.
Before submitting a new issue...
@github-actions[bot] commented on GitHub (Aug 27, 2026):
Thanks for opening this issue, @gongwei-130!
56567052A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@Icedcoco commented on GitHub (Aug 28, 2026):
Thanks for the detailed reproduction and timing data. The metadata and promotion path look healthy; this appears to be a client-side reattachment problem.
From the v0.3.13 code, the first race is confirmed: the new master publishes
master_viewduringTryAcquireLeadership()before its RPC server has completed warmup andasync_start(). With the event-driven watch from #2484, clients can therefore try the advertised endpoint while nothing is listening yet.The ~120-second delay is most likely caused by the RPC client pool retrying synchronously inside
MasterClient::Connect().SwitchLeader()cannot return toLeaderMonitorThreadMain()until theServiceReadyRPC finishes. The pinned YLT defaults are one initial connection plus three retries, with a 30-second connection timeout, which can result in roughly four 30-second attempts. The client-side 1-second retry interval only starts afterConnect()returns, so it does not bound this delay.This is still a high-probability diagnosis rather than a fully confirmed explanation for the reported run. An immediate
ECONNREFUSEDwould normally return quickly and, with the internal one-second waits, would explain only a few seconds. To distinguish these cases, could you provide:begin to connect,connect timeout,failed, andout of max limit.MC_RPC_PROTOCOL,MC_RPC_CONNECT_TIMEOUT_MS, andMC_RPC_TIMEOUT_MS, if set, plus the exact v0.3.13 image digest or commit.async_start,ServiceReady,Ping, andReMountSegmentduring the same interval.Pingrequests stop completely during the two-minute window, or whether they succeed and returnNEED_REMOUNT.RSTor simply time out.Four approximately 30-second connection timeouts would confirm the retry explanation. Immediate refusals followed by ~1-second gaps would point to the pool’s retry wait instead, while successful pings followed by delayed remounts would indicate a separate storage-control-plane issue.
PR #3743 addresses the likely blocking behavior by setting the master RPC pool to a single connection attempt with no internal retry wait, leaving retries to the existing HA monitor and heartbeat loops. It should reduce the stall substantially, but it does not remove the startup ordering race or the remaining per-attempt timeout.
The long-term fix should publish a ready service endpoint only after the RPC listener is accepting connections, while keeping election/fencing state separate from service discovery. Simply moving the existing
master_viewwrite afterasync_start()would need to be evaluated carefully because that key currently also represents leadership ownership.@gongwei-130 commented on GitHub (Aug 29, 2026):
Thanks — this is a much better diagnosis than what we had, and we can now confirm the timing half of it to sub-millisecond precision. Your discriminating test came out cleanly in favour of the retry-blocking explanation, and the data also narrows which connection is blocking in a way that changes what a fix has to cover.
Everything below is from the two
kubectl delete pod <leader>runs on 2026-08-27 already described in the issue. Log lines are the complete set from both store clients over that window — the client log really is this sparse (224 lines total across both pods for 17:44–17:56Z), which is itself part of the answer to Q1.The number you asked for: 121.0 s, four times, deterministic
Gaps between consecutive
client_service.cpp:4857 Failed to ping masterlines (thread 153, the ping thread), on both store pods across both failovers:120 s + 1 s, reproducible to under 1 ms on two independent pods across two separate failovers. That spread rules out network variance — this is a fixed timer, and it matches your1 initial + 3 retries × 30 s connect timeoutplus the 1 s retry interval exactly.(The intervening
17:48:03 → 17:49:54gaps of 111.05 s / 111.22 s are not retry cycles — that is where we issued the secondkubectl delete, which interrupted the loop with an immediateRPC call failed: End of file.)Your alternative is ruled out. Immediate refusals with ~1 s gaps would have produced ~113 log lines in that window. We have zero: the ping thread emits nothing at all between the two lines above.
The refinement: the master was ready for 113.6 s of those 121 s
This is the part that changes the shape of the problem. Master-side admin metrics, 10 s cadence, from the promoted pod:
service_ready=truewithClients: 0for 113.587671 s. The master finished warmup, restored the index, and was accepting — and then sat idle while the clients were stuck. So the outage length is set by the client's retry budget and is essentially independent of when the master becomes ready.Q5, without a packet capture: the two failures have different latencies
We can separate refusal from timeout by timing alone, and they disagree with each other:
82 microseconds apart. That is an immediate
RST, i.e. the new leader's:50051genuinely was not accepting — this is your startup ordering race, and it cost 0.732601 s (the attempt landed that far beforeStarting serve phase...).The 30 s-per-attempt timeouts therefore cannot be against the new leader. The reading that fits all six numbers: the ping thread is retrying the old leader's address, and that is a deleted pod IP — so SYNs are silently dropped rather than refused, which is precisely the condition that turns an instant failure into a 30 s connect timeout. Four of those plus the 1 s interval is the 121 s.
Marking clearly what is inference: the 121 s timer, its start anchor (the first ping failure) and the 113.6 s of idle-but-ready master are measured; that the blocked connect targets the old address specifically is inferred from the latency difference, since we have no per-attempt log line naming the peer.
Which thread gates recovery
The leader-monitor thread (92) fails once, instantly, at 17:50:02.024899 — and then logs nothing until recovery. It is not looping at 1 s. Meanwhile
Mem Storagestays at0 Bthrough the whole window, i.e. the segments are never remounted, and remount is driven off the ping path answeringNEED_REMOUNT. Recovery lands within the same second the ping thread's 121 s expires (Clients: 0 → 2between the 17:51:46 and 17:51:56 samples; ping thread returns 17:51:55.250).So the blocking ping call is what holds the whole reattachment, and the leader monitor's 1 s retry loop — the thing we originally expected to bound this to single-digit seconds — is irrelevant because the remount does not go through it.
Answers to the rest
Q1 — YLT connection log lines: we cannot supply these. Not "we did not capture them": there are zero matches for
begin to connect/connect timeout/failed/out of max limit/coro_rpc/client_pool/yltacross 3 hours and 2551 log lines of both store containers. The published image appears to emit glog only, with YLT's own logger silent. If you tell us the knob (build flag,yltlog-level env, or aeasyloginit call) we will rebuild or reconfigure and rerun the same experiment — this is the one thing you asked for that we genuinely do not have.Q2 — all three are unset, so the pinned YLT defaults are in force, which is exactly the condition your diagnosis assumes:
MC_RPC_PROTOCOLMC_RPC_CONNECT_TIMEOUT_MSMC_RPC_TIMEOUT_MSImage:
kvcacheai/mooncake:0.3.13, digestsha256:51d695314243f3a35615784634ac4fd4bfd5f1aca316df997383a0617169b780, identical on master and store pods. Clients useetcd://for master discovery (not a fixed address),replica_num=1, 2 store clients, 200 objects × 4 MiB.Q3 — master side, full sequence is quoted above. Note there is no
async_startorServiceReadyline in the master log either, so the only observable boundary isEntering warmup phase...→Starting serve phase..., measured at 5.000016 s. Also present, and possibly relevant to you separately:The clients observe the same
master_viewabsence during the election gap — the key is deleted, not stale, so a client reading it mid-election gets a not-found rather than the old address.Q4 — Ping stops completely. It does not succeed, and it never returns
NEED_REMOUNT. Two lines, 121 s apart, nothing in between;Mem Storageflat at0 Bfor the duration.What this implies for #3743
It should fix the observed outage, and for the right reason: a single attempt with no internal retry wait turns the 121 s into roughly the 1 s monitor interval, regardless of which address is being probed or whether it black-holes.
The one thing worth flagging is that fixing the ordering race alone would not be sufficient in general, even though it would have avoided this particular run. The race window is 5 s (the warmup) and the retry budget is 121 s. Any first attempt that fails for any reason — mid-election
master_viewnot-found, a rescheduled pod, a brief network drop — costs the full budget, and the master's readiness has no influence on it. Bounding the client retry is the load-bearing fix; the ready-endpoint separation you describe is the correctness fix. They are not substitutes.Happy to rerun the failover with a capture from a client pod to both the old and new master addresses if that would settle the black-hole-vs-refusal inference, and to retest on #3743 once it lands — we have this on two clusters and can reproduce on demand.