mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #4181] [RFC]: [Store] The client heartbeat path must share no lock or thread with data-plane operations #1053
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#1053
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 @Juhyun-Kim-Memphis on GitHub (Sep 17, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4181
Changes proposed
Make the client heartbeat path share no contended resource with data-plane operations. On
mainaPingwaits forclient_mutex_(held exclusively by every client-state writer), for the calling client's liveness-record mutex (held for the whole lifetime of a serving/retaining guard), and for the one io thread that owns its connection, which also runs data-plane handlers. Any of the three lets a slow control-plane operation stop a live client from being observed, and the master then moves healthy clients toSUSPECTEDand, after the suspicion TTL, toOFFLINE, which offboards their segments and erases the keys on them.Two changes remove the three dependencies:
client_mutex_and the guards, and leavesPingwith only O(1) critical sections (§2.1). Our smaller #4182 did the same onmain's structure; it is closed in favour of #4180.Ping(§2.2). #4180 does not touch RPC threading.This RFC states the invariant they establish together, so that future changes cannot silently reintroduce the dependency.
1. Why this needs a structural fix
In production, one store pod restarting on a master holding 13.9M keys made every one of the 12 live clients expire at once and erased the index from 13.9M keys to 0.43M. That build predated #3533. We reproduced the chain on a rig and described it in #3937: the joining client's
ReMountSegmentwalked all metadata shards under exclusiveclient_mutex_, noPingcould be served, and the monitor expired everyone.On
main, #3533 limits that walk to remounts after an HA promotion, andUnmountSegmentruns its O(keys)ClearInvalidHandlessweep synchronously only with HA, snapshot or CXL enabled. So the known triggers now need one of those modes. In those modes they are still on the rollout path (deploy, roll, scale-out, eviction, node reboot), and the invariant itself is still unwritten.#3936 proposed a circuit breaker for the resulting mass expiry. As @ykwd noted there, that is a mitigation: it withholds the consequence and leaves the cause (#3936 is now closed). #2991 (RFC #2953) made the consequence recoverable, since a heartbeat gap now yields
SUSPECTEDbeforeOFFLINE, but the cause is unchanged, and the number of places that can trigger it grew:client_mutex_holder onmain(d9e4dcbc)MountSegmentReMountSegmentProcessClientOffboardingJobMountLocalDiskSegmentRestoreFromStandbyStateClientMonitorFuncretirement,UpdateClientHostId,ResetStateAfterFailedRestoreAttempt,RebuildClientLivenessAfterSnapshotRestoreand
Pingnow records the heartbeat itself inside that lock (record->Observe()underclient_mutex_shared), where it previously pushed onto a lock-free queue.How the invariant was lost, in four steps that were each reasonable on their own:
ok_client_, the status lookup inPingand exclusiveclient_mutex_inReMountSegment: HA-only, bounded work.Nothing in the code says "
client_mutex_is on the heartbeat path, so keep its critical sections short", so no review could catch any of these. Narrowing one holder's lock restores that promise once; the invariant removes the need for the promise.2. Design
2.1 Lock dimension (#4180). As of
4da335ab,Pinggoes throughClientSessionRegistry::Ping: a lookup under the registry's own mutex (shared),ClientLivenessRecord::Observeunder the record'stransition_mutex_, and a readiness check under the registry mutex (shared) again.client_mutex_,ServingGuardandRetainingGuardno longer exist. Registrations and remounts take a per-client operation lock exclusively, and admitted operations take it shared;Pingnever takes it. #4182 reached the same property onmain's structure with an immutable client view and a try-lock heartbeat; equivalents of its two tests that fail onmainpass against #4180's registry.2.2 Thread dimension (#4183). coro_rpc gives each io thread its own
io_context, binds an accepted connection to one of them for its lifetime, and runs non-coroutine handlers inline on the thread that read the request. APingis therefore read only by the io thread that owns its connection, and if that thread is inside a long handler thePingwaits for it, even when every other io thread is idle. New connections are accepted on io thread 0.--heartbeat_rpc_port(default 0, non-HA) starts a secondcoro_rpc_serverwith one io thread that serves onlyPingandServiceReady, advertised through a newGetHeartbeatRpcPortRPC.MasterClient::Connectdiscovers it and sendsPingthere once it answers, from a connection pool of its own. The main server keeps servingPing, so an old client, an old master, or an unreachable heartbeat server all fall back to today's behaviour with no coordination. This is the design of #3541 on the P2P branch, applied tomain'sPing, with the port discovered rather than configured on every client.#4183 depends on the lock dimension. With a single heartbeat thread, any lock wait inside
Pingstalls every heartbeat at once; without the lock half, one healthy client wentOFFLINEin S1 (§4). It should not be enabled on a master without #4180.3. Proof that the invariant holds (#4180 at
4da335ab+ #4183, non-HA,--heartbeat_rpc_portset)A test can only show that a failure did not occur; it cannot show that it cannot. So the claim is argued from the code, and each step is pinned by a test.
3.1 Locks on the heartbeat path. Every acquisition reachable from
WrappedMasterService::Ping:mutex_, shared (twice)ClientLivenessRecord::transition_mutex_Observe,Evaluate,SetTransitionObserver,StopObserving) do O(1) worklifecycle_mutex_,snapshot_mutex_, metadata shard locks, segment manager lockMasterMetricManager::inc_ping_requestsand liveness gaugescounter_t/gauge_tare per-thread atomicsVLOG(1)per Ping at-v>=1)view_version_is immutable after construction.3.2 Threads.
coro_rpc_serverrpc_thread_num(its ownio_context_pool)Pingfor clients that do not use the heartbeat servercoro_rpc_server(--heartbeat_rpc_port)io_context_pool)Ping,ServiceReadyonlyEach
coro_rpc_serverconstructs its ownio_context_poolfrom itsthread_num, andio_context_pool::run()starts onestd::threadperio_context, so the two servers share no io thread. APingon the heartbeat server is read and executed by a thread that never runs a data-plane handler, and by 3.1 it never waits for one either.ServiceReadyreturns a constant string.3.3 The monitor. #4180's expiry pass captures
nowbefore it starts and evaluates each record under that client's operation lock. A wait inside the pass only delays a decision taken against the earliernow, so a transition still requires that no heartbeat was recorded for the client within the TTL beforenow. By 3.1 and 3.2, recording a received heartbeat never waits for the data plane. The remaining causes are outside the master's data plane: the client, the network, CPU starvation of the whole process, and blocking log I/O.4. Measurement
Measured before #4180 existed, with #4182 as the lock half ("PR-A") and #4183 as the thread half ("PR-B"). #4180 alone is expected to behave like the PR-A rows; that is not measured yet.
Rig: one master (8 RPC threads, 7-CPU / 40 GiB cgroup), 18M keys, 12 clients pinging every second on their own threads, 16 load threads running
BatchExistKey, default TTLs (active 10 s, suspicion 20 s). The same benchmark binary is used for every variant; against a master that does not advertise a heartbeat server it behaves as an unmodified client. Numbers are for the 12 clients that were healthy throughout ("originals"), during the load phase. Each cell is a single run.any_standby_kept_alivebranch so this HA path can be exercised without a failover (not part of either PR). Since #4183 is non-HA only, S1 measures the code path, not a configuration that can be deployed today.--enable_snapshot, soUnmountSegmentrunsClearInvalidHandlessynchronously on the RPC thread. This is a deployable configuration for #4183.¹ Excluding a 7.5 s gap at t=432 s while gdb was attached to the benchmark process to sample its stacks; every
Pingsent in that window returned within 7 ms. Two earlier attempts of this cell ended with the benchmark pod out of memory, a benchmark-side leak unrelated to the heartbeat path.What the table shows:
main, everyPingthat took longer than 2 s completed within 8 ms of anUnmountSegmentreturning: each stall was an io thread working through its queue of unmounts, not lock contention. PR-A does not act here; the 19.8 s vs 29.1 s spread most likely comes from which io thread a heartbeat's connection happened to share with the deepest unmount queue. Only the separate heartbeat thread removed it.client_mutex_behind each remount; one healthy client wentOFFLINEand 1.5M keys were erased. Together they removed every false transition.main, which is why S1 onmainshows dozens ofSUSPECTEDtransitions but no data loss in this run. Each of those transitions still removes a healthy client from every serving path while it is suspected, and the margin toOFFLINEis only the suspicion TTL (S1 with PR-B only crossed it).5. Alternatives considered
rpc_thread_num. APingis read only by the io thread that owns its connection, so k long handlers still stall roughly k/N of the connections, each for the handler's full duration, and new connections still depend on io thread 0.Pinghandler on a worker pool. A generic wrapper at registration needs no list of long handlers, and HA would get it through the same registration. The costs are a thread hop per RPC and more concurrency per connection, a much larger behavioural change than a heartbeat-only server. Returning a coroutine alone does not help: coro_rpc starts it inline on the io thread.OFFLINEand its keys erased (§4).6. Out of scope / follow-ups
--heartbeat_rpc_port(the supervisor's server lifecycle). HA is where the remount walk runs, so this is the most valuable follow-up.Connect.7. Questions for maintainers
Pinghandler on a worker pool (§5), or folding it into the #4180 series?AI assistance disclosure
Claude (Anthropic) helped analyze the code paths, write the PRs and their tests, drive the rig measurements and draft this RFC. The submitter reviewed every changed line and the measurement data.
Before submitting a new issue...
@github-actions[bot] commented on GitHub (Sep 17, 2026):
Thanks for opening this issue, @Juhyun-Kim-Memphis!
32131411A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@Aionw commented on GitHub (Sep 18, 2026):
Hello @Juhyun-Kim-Memphis , I've addressed this issue in https://github.com/kvcache-ai/Mooncake/pull/4180, would mind take a look?
@Juhyun-Kim-Memphis commented on GitHub (Sep 21, 2026):
@Aionw Thanks for pointing me to #4180. I went through it at
4da335ab.Lock side: covered.
Pingno longer waits for anything a data-plane operation can hold for long.client_mutex_and the guards are gone, the registry lock's exclusive sections are O(1) (O(clients) only when a restore commits), and the record'stransition_mutex_is only held for O(1) work. I ported the two tests from #4182 that fail onmainonto the registry:Pingwhile its own remount scope, the restore barrier or another client's registration is held, and while a serving or retainingSessionGuardis held. All pass, and a control that holds the registry lock exclusively does blockPing, so the tests can tell. I've closed #4182 in favour of #4180 and updated this RFC to point at it for the lock side.Thread side: not covered. #4180 leaves
rpc_service.cppuntouched, soPingstill shares the main server's io threads with every data-plane handler. In coro_rpc each io thread runs its ownio_context, an accepted connection stays on one of them for its lifetime, and non-coroutine handlers run inline on the thread that read the request. APingis therefore read only by the io thread that owns its connection: if that one thread is inside a long handler, thePingwaits for it, even when every other io thread is idle. Removing locks fromPingcannot help there, because nothing is waiting on a lock. In our unmount-storm run (18M keys, non-HA with--enable_snapshot, soUnmountSegmentrunsClearInvalidHandlesinline), everyPingthat took longer than 2 s completed within 8 ms of anUnmountSegmentreturning, and healthy clients were markedSUSPECTED. #4180 does not shorten those handlers either: the unmount sweep is still synchronous in these modes, andReMountSegmentstill holdssnapshot_mutex_exclusively for the standby walk.#4183 addresses this with a separate single-thread server for
Ping, and it relies on #4180 for the lock side: with one heartbeat thread, any lock wait insidePingwould stall every heartbeat at once. It is non-HA only for now, while the long remount walk runs only in HA. Before I extend it, which direction would you prefer?RegisterRpcServicethat runs every non-Pinghandler on a worker pool, so the io threads only do I/O. It needs no list of long handlers and HA would get it through the same registration, at the cost of a thread hop per RPC.@Aionw commented on GitHub (Sep 21, 2026):
#4183 would be a grate direction right now. Can you also helping review #4180, so that we can make sure that we actually solve your issues here.
@Juhyun-Kim-Memphis commented on GitHub (Oct 20, 2026):
Closing this RFC. The thread half is resolved by #4601 (merged 10-16): every non-
Pinghandler now runs on a worker thread, so a slow handler no longer delaysPing, and I've closed #4183 as superseded. The lock half, where thePingpath still takesclient_mutex_that the HA standby remount holds exclusively, is being worked on in #4180, #4291 and RFC #4700, so it doesn't need a separate RFC here. If that contention shows up in our deployment, we'll open a new issue with fresh measurements against currentmain.