mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #4508] [Bug]: CleanupStaleHandles removes keys without checking lease, causing lookup hit → load -704 race #1148
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#1148
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 @xuyucheng220 on GitHub (Oct 8, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4508
Bug Report
Mooncake Issue Report:
CleanupStaleHandlesremoves keys without checking lease, causinglookuphit →load-704 raceSummary
In Mooncake store (both the deployed
v0.3.11.post1and the currentmainbranch),CleanupStaleHandles/ClearInvalidHandleserase object metadata based solely onhas_invalid_mem_handle() && is_completed(), without checking whether the object's lease is still active. BecauseExistKey(lookup) andGetReplicaList(load) each onlyGrantLeaseand do notinc_refcnt, a key can be reported as a hit byExistKeyand then erased byClearInvalidHandlesbeforeGetReplicaListruns, producingOBJECT_NOT_FOUND (-704)on a key that was just reported as present.This is a time-of-check-to-time-of-use race between the background
ClientMonitorFunccleanup and the two-step (lookup → load) access pattern used by vLLM-Ascend's KV pool. It is not fixed onmain.Environment
v0.3.11.post1(production) andmainbranch (verified via GitHub raw source)Observed symptom in production
Across three production clusters over ~7 days (2026-10-01 to 2026-10-08),
batch_get_into_multi_buffersreturns-704(OBJECT_NOT_FOUND) for keys thatbatch_is_exist(ExistKey) had just reported as present:-704accounts for >99.6% of all non-zero return codes fromget.Eviction: Success/Attemptsis delta per Admin Metrics cycle (~10 s), not cumulative (verified: consecutive non-zero rows are1/1with0/0in between, not monotonically increasing).-704drops to near-zero and then gradually accumulates again as the cluster runs. This correlates with buffer invalidation (has_invalid_mem_handle) accumulating over time, not with eviction pressure.Root cause (source-level)
1.
ExistKeyandGetReplicaListonlyGrantLease, neverinc_refcntmaster_service.cpp:GrantLeaseonly extendslease_timeout; it does not touchrefcnt:2.
BatchEvict(normal eviction) DOES respect lease — this is NOT the culpritBecause
ExistKey/GetReplicaListbothGrantLease, a key that was just looked up is protected fromBatchEvictfor the lease TTL. Eviction alone cannot explain lookup-hit → load--704.3.
CleanupStaleHandles/ClearInvalidHandlesdo NOT check lease — this IS the culpritCleanupStaleHandlesremoves replicas whose memory handle is invalid (i.e. the segment was unmounted / buffer allocator expired) regardless of whether the object's lease is still active. When all replicas are removed andIsValid()becomes false, the key is erased from metadata.4.
ClientMonitorFunctriggers this cleanup on a background threadRace window
The lease that
ExistKeyjust granted provides no protection againstCleanupStaleHandles, because the cleanup path never callsIsLeaseExpired().Why this matters for vLLM-Ascend
vLLM-Ascend's KV pool uses a two-step access:
lookup()(callsbatch_is_exist) to find the longest cached prefixload()(callsbatch_get_into_multi_buffers) to fetch the cached KVWhen
lookupreports a hit butloadreturns-704, the connector treats it as a load failure and recomputes the prefix. In the current vLLM-AscendSAVE-CLAMPpatch (a site-local poison-KV guard), a-704load failure triggersrecord_failed_blocks→_record_load_failure→SAVE-CLAMPtruncates the recomputed KV before writeback. Because-704means "missing" (the data is genuinely gone), the recomputed KV is correct and safe to write back — but the truncation discards it, so the next request hits-704again. Over time this prevents the cache from being rebuilt, inflating TTFT for long multi-turn conversations.Even without the site-local patch, the lookup-hit → load-
-704race itself is a correctness/usability issue: the client receives contradictory answers from two consecutive RPCs for the same key within milliseconds.Verification that
mainis also affectedFetched
mooncake-store/src/master_service.cppfrom themainbranch. TheCleanupStaleHandleswrapper andClearInvalidHandlesonmain:ClearStaleHandlesonmainadds multi-tenant and quota accounting, but the predicate still does not includeIsLeaseExpired. The lease guard is present inBatchReplicaClear(L4103) andPutStart(L6055) onmain, but not in theClearInvalidHandles/CleanupStaleHandlespath.Suggested fix
Add a lease check at the top of
CleanupStaleHandlesso that an active lease delays cleanup of stale-but-still-leased objects:Trade-offs
-704race; the two RPCs become consistent for the duration of the lease.has_invalid_mem_handlemeans the allocator is invalid). So the cost is a brief delay in metadata cleanup, not a memory leak.GetReplicaListre-validate that the returned replica descriptors still have valid handles before returning, and retry/skip invalid ones instead of returning-704for the whole key. But this is more invasive.Questions for the maintainers
CleanupStaleHandlesintentionally ignores the lease (e.g. "buffer gone = data gone, no point keeping metadata")? If so, would you consider the narrower alternative (re-validate inGetReplicaList)?ExistKey→GetReplicaListpattern (withGrantLeaseon both) an intended usage contract? If yes, the lease should be honored by all cleanup paths.IsLeaseExpiredguard toCleanupStaleHandlesbe acceptable, or do you prefer theGetReplicaList-side re-validation?Reproduction
The race is inherently timing-dependent, but the conditions that make it frequent in production are:
batch_is_existfor every prefill chunk)ClientMonitorFuncrunning every few seconds withclient_live_ttl_sec_on the order of secondsAfter a fresh leader switchover (metadata cleared) the race is quiet and then accumulates as buffer invalidations accumulate — this is the signature we observe.
Happy to provide the full production log excerpts, the
-704time series, and theClientMonitorFunc/unmount_expired_mem_segmentcorrelation if useful.Before submitting...
@github-actions[bot] commented on GitHub (Oct 8, 2026):
Thanks for opening this issue, @xuyucheng220!
132881373A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@xuyucheng220 commented on GitHub (Oct 8, 2026):
Summary
In Mooncake store (both the deployed v0.3.11.post1 and the current main branch), CleanupStaleHandles / ClearInvalidHandles erase object metadata based solely on has_invalid_mem_handle() && is_completed(), without checking whether the object's lease is still active. Because ExistKey (lookup) and GetReplicaList (load) each only GrantLease and do not inc_refcnt, a key can be reported as a hit by ExistKey and then erased by ClearInvalidHandles before GetReplicaList runs, producing OBJECT_NOT_FOUND (-704) on a key that was just reported as present.
This is a time-of-check-to-time-of-use race between the background ClientMonitorFunc cleanup and the two-step (lookup → load) access pattern used by vLLM-Ascend's KV pool. It is not fixed on main.
Environment
Mooncake store: v0.3.11.post1 (production) and main branch (verified via GitHub raw source)
Workload: vLLM-Ascend KV transfer (MooncakeConnectorV1 + AscendStoreConnector), GLM-5.2-w8a8c8, 8–12 node clusters, TP16/DP2 prefill, TP4/DP8 decode
Three independent clusters, each with its own mooncake master HA (etcd-backed leadership)
@he-yufeng commented on GitHub (Oct 8, 2026):
Verified against current main (
dcddb56c). The mechanism reads as you describe, and there is a design-intent mismatch on top of it.The lease is meant to cover exactly this window. GetReplicaList grants it with the comment "so it will not be removed when the client is reading it" (master_service.cpp:4440-4444), and ExistKey refreshes the same lease (3303). Essentially every other removal path honors it: Remove (7397), the eviction paths (7476, 7598, 7734), quota reclaim (8169, 8350, 11512, 12485). The stale-handle sweep is the one removal path that never looks at the lease: the background ClearInvalidHandles (193, 2761) drops replicas where (invalid mem/nof handle or stale disk client) && completed (2771-2775), and when that empties the replica set the metadata is erased outright (2847-2851, EraseMetadata kFull) with no IsLeaseExpired consult anywhere on the path.
Two reasons this matters beyond the error-code flip:
Fix shape I would propose: keep the replica-level cleanup as is (dropping genuinely stale replicas is fine), but make the metadata erase lease-aware. If the object's read lease is still active, skip the EraseMetadata and leave the (possibly empty) metadata in place until the lease expires; from then the normal sweep takes it. That closes the lookup-then-load race for the TTL window, keeps remount/rebuild recovery intact, and stays consistent with how every other removal path treats the lease. The HA oplog flavor needs the same gate at the durable-finalize erase, matching the existing in-memory semantics.
Do you want to send the PR? Your report did the diagnosis, so first right of refusal is yours. Otherwise I can pick it up.
@he-yufeng commented on GitHub (Oct 8, 2026):
PR up: #4521.
The sweep now skips a record whose read lease is still live whenever the round would finish it (every replica stale, or the metadata already invalid), and the HA oplog REMOVE is held at the same decision point, so standby replay cannot erase early either. Regression test reproduces the lookup -> load race both ways: red without the gate, green with it. Full master_service_test 68/68 locally.
One honest scope note: inside the window, loads still report OBJECT_NOT_FOUND because the handles genuinely are invalid at that point. What changes is that the record survives, so the remount/rebind path revalidates the same metadata and the object comes back, instead of being orphaned at the first sweep. The failure becomes transient and bounded by the lease TTL.
@xuyucheng220 commented on GitHub (Oct 9, 2026):
ok, we will use your PR to test, thanks a lot.
@Aionw commented on GitHub (Oct 14, 2026):
Thanks for the detailed write-up. Before going further with the fix, I think the diagnosis needs one more step, because the
-704itself is intended behavior here.Once a segment is unmounted, its memory can no longer be trusted, so readers must not see it. After unmount, the master has no guarantee about what that memory still holds. The owning client may have exited, released the buffer, or reused it, and a remote read could return stale or overwritten bytes. That is why
UnmountSegment→PrepareUnmountSegmentinvalidates the allocator registration right away. From that momenthas_invalid_mem_handle()is true,IsValid()is false, andGetReplicaListreturnsOBJECT_NOT_FOUND, all beforeClearStaleHandlesruns. Hiding the object at unmount time is the intended behavior. The sweep only cleans up the metadata afterwards. #4521's own regression test (SweepDefersToActiveReadLease) confirms this: reads returnOBJECT_NOT_FOUNDafter unmount while the record is still retained.So I don't think #4521 addresses this report. It changes when the record is erased, not what reads return. The remount-revival argument does not apply here either. On current main (
ba6d44d96) the only caller ofreplace_memory_bufferis the standby-restore path inMountSegment(master_service.cpp:1106), which only runs for segments a newly promoted leader restored and kept alive (standby_accounted_memory_bytes_). After a plain client-expiry → unmount → remount, the master cannot vouch for what the old buffers contain, so the old replicas are not exposed to readers again.The real question is why "ExistKey hit, then the replica becomes unreadable" happens thousands of times a week. There are two candidates, and the report doesn't yet tell them apart:
client_live_ttl_sec(a false expiry, e.g. master overload)? The second case would be the actual bug.default_kv_lease_ttlin v0.3.11.post1 is 5 s. If the connector's lookup → load gap sometimes exceeds that (queueing, large prefill batches),BatchEvictcan legitimately evict the key in between. The eviction volume in your table is large, and "near zero after a fresh leader, then gradually accumulating" also fits a store that starts empty and fills up until eviction kicks in.Could you share:
-704bursts: client-expiry and segment-unmount events, plus eviction rounds, with timestamps, so we can see which one lines up with the-704time series.default_kv_lease_ttl,client_live_ttl_sec, and the eviction high watermark/ratio.Separately, whatever the cause turns out to be, a
-704after a hit is a cache miss. The connector should recompute and write back. The site-localSAVE-CLAMPtruncation turns a one-off miss into a persistent one, so that part is worth fixing on the vLLM-Ascend side regardless.@xuyucheng220 commented on GitHub (Oct 14, 2026):
thanks for your reply. i have patched pr-4521 in my ENV days ago, and also have found and fixed the problem of vllm-ascend yesterday( it also caused so many -704 error code, the issue link: https://github.com/vllm-project/vllm-ascend/issues/18509)