Repository navigation
[Store] Defer a client mass expiry the master cannot rule out as its own stall - #3936
Juhyun-Kim-Memphis wants to merge 1 commit into
Conversation
…own stall ClientMonitorFunc expires a client whose heartbeat deadline has passed, and expiring one unmounts its segments and erases every key they held. Nothing in the master undoes that. A transient stall inside the master longer than client_live_ttl therefore does not degrade the cluster, it destroys it: every tracked client's deadline passes in the same tick, and one pass of the monitor erases the whole cluster's cache index while every store process is still alive and still holding the data it just lost the index for. The monitor cannot separate that from a real fleet death by looking at heartbeats, because both produce the same reading: no heartbeats. So the rule judges the cause instead. Ping's whole body is a shared-lock read of ok_client_ and a push onto a lock-free queue, so an exclusive holder of client_mutex_ is the one thing that can stop the handler running at all -- and the master knows when it holds its own lock. Every site that takes client_mutex_ exclusively records its window (ExclusiveClientLockWindow), and a tick whose window overlaps a recorded hold defers its candidates rather than expiring them. A symptom-based rule does not work here. A fraction or a count of simultaneously expiring clients is defeated by an expiry that splits across consecutive ticks: each tick then sees a minority and expires it, and two ticks erase the cluster's index as thoroughly as one would. Evidence about the master is immune to how the candidates happen to fall across tick boundaries, which is why the rule is built on it. The hold window opens at last_ping_batch_at, the last tick that popped a heartbeat. A completed hold counts only while no heartbeat has been popped since it released: once one has, the master is demonstrably serving Ping again, that hold is refuted as an explanation, and a candidate still overdue is genuinely gone. This is what keeps a routine short mount from deferring a real client death for the whole grace period. That edge follows the heartbeat stream rather than the wall clock on purpose. A hold can release and leave the master starved for many more ticks -- the UnmountSegment -> ClearInvalidHandles sweep holds snapshot_mutex_ across all shards, observed at 8.5-9.6 s with 14M keys -- and through that no heartbeat is popped, so last_ping_batch_at does not advance and the released hold stays evidence. A "holds within the last tick or two" window would age that evidence out in the middle of the stall and erase the keys. A weaker secondary condition covers starvation that records no hold at all, as io threads blocked on snapshot_mutex_ or on shard locks starve Ping too: the master has popped no heartbeat since the newest candidate went overdue, and it tracks at least two clients. The client floor matters, because with a single tracked client "nobody pinged" and "that client died" are the same sentence, and reading it as a stall would hold back every store restart in a single-client cluster for the whole grace. The behaviour change a reviewer has to weigh: a genuine simultaneous death of every client is deferred, bounded by client_mass_expiry_grace_sec. A vanished fleet and a totally stalled master produce identical heartbeat streams and no reading of that stream separates them, so the breaker takes the bounded error -- stale segments for the grace period, on segments whose owners are all gone -- over the unbounded one. The judgement itself is the pure function EvaluateClientMassExpiry over ClientMassExpiryInputs, so its boundary cases are a table test with no monitor thread running. Two flags configure it, --client_mass_expiry_guard (default true) and --client_mass_expiry_grace_sec (default 60); either a false guard or a zero grace leaves the master with no stall detection at all, and the monitor logs that at startup. Two metrics report it: master_client_expiry_deferred_total counts deferring ticks and master_client_expiry_deferred_clients holds how many clients are being held back right now. One gap stays open. A single-client master starved with no client_mutex_ hold recorded has neither condition to go on, and closing it needs a liveness signal off the data path that this change does not add.
|
Thanks for identifying and raising this issue. This is indeed a serious problem that we should address. That said, the current proposal feels more like a temporary mitigation. @Aionw will also help take a closer look at this issue so we can work toward a more robust solution. |
|
@ykwd thanks — agreed that this PR is a mitigation. We went after the cause and filed it as #4181, with two small PRs:
On a rig with 18M keys, a burst of 12 joining clients (running the post-promotion shard walk) made healthy clients With that in place, this breaker is no longer the fix. If you still want it as a safety net, its evidence should be generalized to "no heartbeat processed for any of two or more clients in a tick", as discussed in #4181 §5. Happy to rework or close this PR, whichever you prefer. @Aionw |
|
Thanks for the explanation. I recently opened a PR #4180 that introduces a new abstraction, ClientSessionManager, to manage client liveness in one place — we may be able to use it to fix the issues found here as well. |
Move registration and resource-operation serialization into replaceable client slots, keeping liveness observations independent of slow remount work. Preserve incarnation validation, restore coordination, and readiness accounting without long-held registry locks. Cover concurrent admission, rollback, removal, restore, observer shutdown, and slot replacement. Add a disabled manual regression experiment for kvcache-ai#3936. Validation: 249 tests passed across liveness, session manager, MasterService, HA, and segment suites; liveness/session suites repeated 50 times. The kvcache-ai#3936 fault-injection experiment also passed. File-scoped prek checks passed except the unavailable clang-format-20 hook; changed lines were checked with clang-format 22. AI-assisted implementation and testing.
Description
The failure mode
ClientMonitorFuncexpires a client whose heartbeat deadline has passed, and expiryis irreversible: the client's segments are unmounted and every key they held is
erased from the index. The monitor decides this from the absence of heartbeats,
which looks exactly the same whether the clients died or the master stopped serving
Ping.So any stall in the master longer than
client_ttl(default 10 s) makes it expireevery client at once and destroy the whole cluster's cache index. A transient
control-plane stall becomes permanent, cluster-wide data loss.
We hit this in production and reproduced all of it.
Ping's body is a shared-lockread of
ok_client_plus a push onto a lock-free queue, so an exclusiveclient_mutex_holder is the one thing that can stop the handler running at all —and because coro_rpc runs a non-coroutine handler inline on the io thread, io
threads blocked on that lock stop reading pings off their connections entirely. We
measured
master_ping_requests_totalcompletely flat for nine consecutive secondswhile one such hold ran.
Why this is an operational problem, not just a rare edge case
The trigger sits on the rollout path.
ReMountSegmentis the only way intook_client_, so every store client start goes through it — a deploy, a rollingupdate, a scale-out, an eviction, a node reboot, a DaemonSet update. The operations
run most often are the ones that can wipe the cluster's cache.
And the penalty is charged in exactly the currency operators are trying to save.
The loss is the cache, and re-warming a cache is far more expensive than the pod
boot that preceded it, so a fleet roll turns into a roll followed by a long cold
period. The joining node's own cache also comes online late, because its segments
are not usable until its remount returns. The rational response is to avoid
restarting store pods at all — which is the opposite of fast, frequent, safe
deploys, and it blocks the ordinary capacity work of scaling a cell out and back in.
For scale: on our rig, the metadata scan in
ReMountSegmenttook 0.72 s at 1Mkeys, 9.76 s at 14M and 13.2 s at 18M, against a 10 s
client_ttl— a threshold atroughly 14.2M keys. One of our production deployments holds 14.6M, so it sits above
the threshold in steady state; a single store pod restart there expires all of its
clients.
#3533 removed the largest scan on that path, and it helped. But the exposure is
structural rather than tied to that one scan: any exclusive
client_mutex_holdthat outlasts
client_ttlreproduces the same irreversible outcome. There is alsoan inconsistency between the two defaults —
client_ttlis 10 s while the client'sown RPC wait is coro_rpc's 30 s default, so the master gives up on a client well
before that client gives up on the master.
What this PR adds is the property that makes the join path safe to keep optimizing:
a regression there becomes a bounded deferral instead of a cluster-wide cache loss.
What the change does
A circuit breaker in
ClientMonitorFunc. When a tick's expiry candidates cannot bedistinguished from the master's own stall, it holds them back — a true no-op, with
no unmount prepared and no sweep started — bounded by
client_mass_expiry_grace_sec. Recovery needs no machinery: the next queue drainrefreshes the deadlines and the episode closes itself.
The judgement is a pure function,
EvaluateClientMassExpiry, over one tick'sreadings, which is what lets its boundary cases be a table-driven test with no
sleeping.
It judges the cause, not the symptom. The primary condition is that an exclusive
client_mutex_hold overlapped[last_ping_batch_at, now], the stretch over whichthis master has no heartbeat of its own to show. Every site taking that lock
exclusively records its hold window into relaxed atomics; the monitor's own expiry
branch deliberately does not, so a previous tick's expiry cannot come back as
evidence that the master was stalling.
Two things about that window are load-bearing:
released. Once one has, the master is demonstrably serving
Pingagain, thathold is refuted as an explanation, and a candidate still overdue is genuinely
gone — so an ordinary single-client death is not deferred.
releases, the master can stay starved for many more ticks; through that no
heartbeat is popped, so the released hold stays evidence. A window of "holds
within the last tick or two" would age out mid-stall and erase the cache.
A secondary condition covers a total stall with no hold recorded: the master's own
silence, gated on there being at least two tracked clients — with exactly one
client, hearing nothing says only that that client stopped, which is the client's
own problem and must be acted on at once.
We tried symptom-based rules first — "too many clients expired in one tick" — and
they were defeated twice by the same thing: a deadline is stamped by the tick that
popped that client's heartbeat, so a stall's expiries spread across consecutive
ticks and each tick's share falls under any threshold. That is why the rule is
cause-based, and it is worth not simplifying back.
The behaviour change a reviewer should weigh
A genuine simultaneous death of every client is deferred up to the grace bound
before being expired. This is inherent, not an oversight: a fleet that has vanished
and a master that has totally stalled produce identical heartbeat streams — none —
so no rule can separate them. The cost is bounded and observable
(
master_client_expiry_deferred_total, and aLOG(ERROR)when the grace isexceeded), and it buys the difference between a bounded delay and an unrecoverable
cache wipe.
Two gaps stay open and are stated in the code:
client_mutex_hold — all io threads busy on shard locks, say — records no hold, and one tracked
client is not silence evidence, so that deployment's keys still go. Closing it
needs a liveness signal off the data path.
The guard is on by default and
client_mass_expiry_guard=falserestores theprevious behaviour exactly.
Verification
Reproduced and gated on a 13-client deployment with the master under the same 8-CPU
and 60 GiB limits it runs with in production.
against a 10 s TTL. With the breaker: zero expiries, key count held at
18,000,000, active clients never fell below the 12 long-lived ones, and the
episode closed itself three ticks later when the hold released. Without it, all
13 expire in one tick and the sweep erases the keys.
keep pinging expires after one TTL (10.66 s), with zero deferral ticks. Under a
pinned window instead of
last_ping_batch_at, the same case was deferred for thefull 60 s grace — which is what motivated that edge.
SIGKILL; a genuine stall defers on hold evidence alone and preserves its keys.Unit tests on this branch, built and run against
8715bb7c:master_service_test80/80,
master_service_config_test9/9, the breaker's own 11 cases(7 wall-clock, 4 pure) 11/11, and
master_service_group_test13/13, whichis the other consumer of the fixture header these tests extend. The tests were also
checked for discriminating power rather than just for passing — deleting the primary
condition fails only the stall and tick-split cases, and pinning the window's left
edge fails only the ordinary-death case.
Happy to split the breaker and its tests differently, or to trim the reasoning at
ClientMonitorFunc, if that suits review better — the comments are there mainly sothe rule is not later simplified back into a count of expiring clients, which does
not work.
Module
mooncake-store)Type of Change
How Has This Been Tested?
Built against
8715bb7cand run in a container with the master's productionresource limits (8 CPU, 60 GiB).
Test commands:
cmake -B build -DBUILD_UNIT_TESTS=ON ninja -C build master_service_test master_service_config_test \ master_service_group_test mooncake_master ./build/mooncake-store/tests/master_service_test ./build/mooncake-store/tests/master_service_config_test ./build/mooncake-store/tests/master_service_group_test # the breaker's own cases ./build/mooncake-store/tests/master_service_test \ --gtest_filter='*ClientMassExpiry*:*ClientExpiry*'Test results:
master_service_test80/80 (207 s),master_service_config_test9/9,master_service_group_test13/13,breaker subset 11/11 (46 s). Zero warnings in the build.
deployment against a master under production limits: the 18M-key stall,
the ordinary single-client death, and both single-client directions.
Checklist
./scripts/code_format.sh— clang-format20.1.0 reports zero replacements on every file touched
not run; happy to do so if the hooks are expected to be green before review
1637 insertions, of which 985 are tests and the test fixture and ~660 are
production code, a large part of that the reasoning comments at
ClientMonitorFunc. Please say if you would rather have an RFC issue firstand I will open one and hold this PR behind it.
AI Assistance Disclosure
Claude Code was used throughout, under human direction: to reproduce the failure on
a rig, to implement the breaker and its tests, and to run the verification above.
Two earlier symptom-based designs it produced were rejected during review after
being shown defeatable, which is why the rule is cause-based. The human submitter
has reviewed every line and is responsible for defending the change.