mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #2306] [RFC]: Master-Side LOCAL_DISK Replica Warm Re-Adoption Across Client Restart #629
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#629
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 @yzhan1 on GitHub (Jun 3, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/2306
[RFC]: Master-Side LOCAL_DISK Replica Warm Re-Adoption Across Client Restart
Summary
When a Mooncake client (e.g., vLLM PD instance using ModeA) restarts, the master expires it after
client_live_ttl_sec(default 10 s) and immediately erases all of its segment registrations andLOCAL_DISKreplica metadata. The on-disk SSD files survive but become master-side invisible. The new client must rediscover everything viaFileStorage::ScanMeta— and for the default OffsetAllocator backend, today'sScanMetais in-memory only (storage_backend.cpp:3124) so it recovers nothing at all until PR #2215 lands. On the other backends, recovery is slow but possible (see §Motivation). Prefix-cache hit rate drops to ~0% during the gap.This RFC introduces:
LocalDiskReplicaStateenum (OK → DISCONNECTED → REMOVED) carried in-place on every LOCAL_DISK replica, with a grace timer instead of immediate erase. A returning client transitions aDISCONNECTEDreplica back toOKatomically.local_disk_segment_idbound to an on-SSD marker file at${storage_path}/.mooncake_local_disk_segment_idthat identifies the storage location (independent of the per-process randomclient_id).A returning client whose marker file matches a
DISCONNECTEDsegment is auto-adopted via the existingMountLocalDiskSegmentRPC. NoScanMetawalk needed.Scope — what this RFC does and does not enable:
replica_num >= 2for this)DISK(3FS/shared FS) instead ofLOCAL_DISKDiskReplicaDatahas noclient_id; client lifecycle is decoupled from data lifecycleThis RFC is scoped to
LOCAL_DISKbecauseDiskReplicaDatadoesn't have the owner-coupling problem —file_pathis independent of any client, soCleanupStaleHandlesnever touches DISK replicas when a client dies. Operators who want to avoid this bug entirely by using a shared-FS offload tier can do so today; the trade-off is read latency (3FS network roundtrip vs LOCAL_DISK's microsecond RDMA). See Alternative I.The fix is independent: works for all SSD backends, works for graceful and SIGKILL exits, and ships valuable on its own. Composes with PR #2215 and RFC #1920's Client Graceful Shutdown work.
Motivation
The Mooncake paper (arXiv:2407.00079 §4.2, Table 1) reports from Moonshot/Kimi's production trace: block size 512 tokens, mean input length 7,590 tokens, prefix cache hit rate climbing from 30% at 1K blocks to 51% at ∞, with a heavy hot-block skew (a small set accessed 10K+ times).
When a client/PD instance restarts, today's master treats its
LOCAL_DISKreplicas as gone. The data is still on the SSD, but invisible — readers miss, and the next prefill recomputes the full prompt. The lost cache is concentrated value (the hot blocks); a cold restart drops the hit rate toward 0 until traffic re-warms it, inflating TTFT for a workload where prefill already dominates.Recovery behavior varies sharply by SSD backend:
ScanMetais in-memory only (storage_backend.cpp:3124, V1); PR #2215 fixes this with snapshot-based recovery, but the master-side gap remains until ingest completesScanMetawalks every file and reads its content (storage_backend.cpp:1149)storage_backend.cpp:1564), then iterates in-memoryEven with PR #2215, OffsetAllocator's recovery window is bounded below by
ClientMonitorFuncTTL (10s default) + snapshot deserialization +NotifyOffloadSuccessingest — measured in tens of seconds to minutes depending on cache size. This RFC closes that window to a single adoption RPC.The goal of this RFC is to keep that data visible across the restart so the master can re-adopt it instead of waiting for a full re-ingest.
Goals
LOCAL_DISKreplicas across client restart on all SSD backends.MountLocalDiskSegment(today: 0% during the per-backend recovery window described in §Motivation).client_idas the per-process identity; persistent identity is data-bound (marker file).Non-Goals
replica_num >= 2or shared DISK tier (3FS).client_ttlmechanism (§6), which is correctness-safe but means a worst-caseclient_live_ttl_sec + disconnect_grace_period_secgrace window across a failover instead ofdisconnect_grace_period_sec. A future PR can persist DISCONNECTED via snapshot schema bump or etcd sidecar once broader rolling-upgrade work (RFC #1920) makes the migration cheap.Background
Today's
LOCAL_DISKlifecycle is an implicit two-state model: live → erased.ClientMonitorFunc(master_service.cpp:4914) sees no Ping for 10s → callsClearInvalidHandles→CleanupStaleHandles(master_service.cpp:2530) erases every replica whose owner client_id is no longer alive. No grace period.Key paths to know:
client_idper processclient_service.cpp:60types.h:95master_service.cpp:4914master_service.cpp:2530master_service.cpp:2706(NotifyOffloadSuccess)master_service.cpp:351(ReMountSegment)replica.h:248(fn_is_completed)master_service.h:53-57(client_mutex → shard mutex → segment_mutex)master_service.h:1402(DiscardedReplicas, for write-path timeouts only)What's new vs. existing patterns: the proposed
LocalDiskReplicaStateis orthogonal toReplicaStatus.ReplicaStatusdescribes the data's write lifecycle (COMPLETE= bytes are written); the new state describes the owner's liveness lifecycle (DISCONNECTED= owner is currently unreachable, may return). A replica can beCOMPLETE+DISCONNECTEDsimultaneously. The delayed-delete pattern fromDiscardedReplicasis the conceptual ancestor (TTL on cleanup), but this RFC annotates the replica in place rather than moving it to a separate list — necessary because adoption needs the per-key linkage.Design
1. State machine
DISCONNECTEDis invisible to readers (see §3). TheDISCONNECTED → OKtransition (when a returning client with matching identity callsMountLocalDiskSegment) is atomic under the shard mutex — readers never see a half-adopted state.2.
LocalDiskDescriptorextensionstateanddisconnect_grace_expiryare master-side only; clients never read them.local_disk_segment_idis visible to clients as opaque routing metadata — clients don't make decisions on it; master uses it for adoption matching (see §4).3. Reader visibility
Add
fn_is_visible_to_reader:GetReplicaList(master_service.cpp:842) switches fromfn_is_completedtofn_is_visible_to_reader. The predicate is essential: extending replica lifetime past client death (to enable adoption) means a reader could otherwise be handed atransport_endpointpointing at a dead process and fail the RDMA. With the predicate, DISCONNECTED replicas are master-side bookkeeping only and never appear in reader-facing lookups.4. Persistent storage identity (
local_disk_segment_id)The marker file is the load-bearing identity primitive.
Client::Client()bootstrap rule:Properties:
Two IDs, two purposes:
client_id— random perClient::Client()invocation; identifies "the process currently talking to master"; used for liveness, heartbeats, refcount ownership, DRAM segment registration.local_disk_segment_id— read from marker file on the SSD; identifies "this storage location"; used only for warm re-adoption matching.A vLLM restart creates a new
client_id(the new process is a new process — new PID, new RDMA endpoint, new pinned memory) but reads the samelocal_disk_segment_id(the SSD didn't change). The master seesclient_idchange andlocal_disk_segment_idstable → "this is the storage returning under a new process."5. Adoption via extended
MountLocalDiskSegmentExtend the existing mount:
Master logic: look up
local_disk_segment_idin thedisconnected_segments_map. If present and epoch matches (or none expected) → atomicDISCONNECTED → OKtransition, returnReattachedFromDisconnectedwith the key list. If absent → normalFreshMount.6. Race mitigations
Six mechanisms; each is individually necessary for correctness.
DISCONNECTEDstate + grace timer + EvictionThread reaperlocal_disk_segment_idmarker file (§4)fn_is_visible_to_readerpredicateDISCONNECTEDBatchEvictpromotion_tasks,dec_refcnt()on local_disk source, drop taskmaster_service.cpp:2890inc_refcnt()on LOCAL_DISK); replica becomes inert (refcnt > 0 blocks eviction even after reaper sweep). Scope is limited to promotion: offloading and replicationinc_refcnt()calls (lines 1399, 1935, 2151) land on memory replicas, whichCleanupStaleHandles+has_invalid_mem_handle()already clean up.client_ttlat new-leader bootClientMonitorFuncmain loop: pre-populateclient_ttl[id] = now + client_live_ttl_secfor every distinctclient_idinsegment_manager_ClientMonitorFuncpopulatesclient_ttlonly from inbound pings (master_service.cpp:4923). On a new leader, dead clients never ping, so they never enterclient_ttl, so the expiry path never fires for them. Their LOCAL_DISK replicas — restored from snapshot as state=OK because DISCONNECTED is in-memory only — stay visible to readers indefinitely, routing reads to dead RDMA endpoints. Seeding gives each snapshot-restoredclient_ida one-time deadline atboot + client_live_ttl_sec; live clients refresh it via heartbeats, dead clients let it expire, and the existing expiry path transitions them to DISCONNECTED.Distinction from existing
grace_period_ms(UnmountSegmentByIdatclient_service.h:313): the codebase already has a per-segment cooperative-shutdown grace (MasterService::GracefulUnmountSegment+GracefulUnmountScheduler). It is triggered by an explicit client RPC ("I'm going away on purpose, hold the segment for N ms so in-flight readers can finish"), keeps the segment visible during the grace window, and ends in a final commit-unmount. This RFC'sdisconnect_grace_period_secis a sibling-but-distinct mechanism: triggered by master observation of a TTL trip (unplanned death), hides the segment during the grace window, and can end in re-adoption rather than final removal. Different triggers, opposite visibility semantics, different outcomes — they cannot share a knob.Failover semantics:
GetReplicaListgates only onfn_is_completed, so a snapshot-restored OK replica stays visible regardless ofok_client_membership. The RDMA data path does not flow through master, so live clients continue serving the moment the new leader can answer RPCs.client_live_ttl_secof boot via seededclient_ttl, then hidden by the visibility predicate. Reader behavior during that window is RDMA-fail-then-fallback; after it, clean miss.client_ttlexpiry, and resume their grace window from the new-leader-boot timestamp. Worst-case grace extends toclient_live_ttl_sec + disconnect_grace_period_sec.Out of scope:
kSnapshotSerializerVersioncheck make additive schema changes a hard cutover; coordinating with broader rolling-upgrade work (RFC #1920, PR #1776) is the right time to revisit. In-memory + seededclient_ttlis correctness-safe in the meantime.7. Configuration
8. Metrics
Implementation Plan
DISCONNECTED state is in-memory only and not serialized. The snapshot format serializes
LocalDiskDescriptorvia YLT_REFL (positional msgpack) andkSnapshotSerializerVersion(catalog_backed_snapshot_provider.cpp:29) is a hard string match, so adding fields would be a hard cutover. The design follows the existingGracefulUnmountSchedulerprecedent (in-memory, lost on failover); failover-safety comes from the seededclient_ttlmechanism in §6.Files touched:
replica.h: addstate(in-memory enum) +disconnect_grace_expirytoLocalDiskDescriptor; mark non-serialized.master_service.{h,cpp}: modifyCleanupStaleHandlesto transition LOCAL_DISK to DISCONNECTED instead of erasing; adddisconnected_segments_map keyed bylocal_disk_segment_id; add reaper sweep (piggyback onEvictionThread); seedclient_ttlatClientMonitorFuncstartup fromsegment_manager_; modifyMountLocalDiskSegmentfor adoption; new visibility predicate; eviction guard.client_service.cpp: marker-file bootstrap (O_CREAT | O_EXCLwrite, normal restart read, moved-disk error, auto-migrate path).master_client.{h,cpp},rpc_types.h,rpc_service.{h,cpp},segment.{h,cpp}: wire new fields onMountLocalDiskSegmentRequest/Response.PR plan (split for reviewability):
LocalDiskDescriptorextension (§2, in-memory fields), reader visibility predicate (§3), marker file bootstrap (§4), adoption RPC extension (§5), eviction guard, reaper, seededclient_ttl. The end-to-end fix for #2254.promotion_taskson OK→DISCONNECTED, drop the local_disk source refcnt, remove the task. Small; depends on promotion-on-hit landing more fully.client_ttlalready makes failover behavior no-worse-than-today.Tests:
local_disk_disconnect_test.cpp(one test per race mechanism, includingSeededClientTtlExpiresDeadClientPostFailover),local_disk_segment_id_bootstrap_test.cpp(marker file + moved-disk error), pybind end-to-end. No snapshot round-trip test — disconnected state is intentionally not serialized.Acceptance: issue #2254 reproducer hit rate ≥95% within 1s of
MountLocalDiskSegment; moved-disk case fails loud; post-failover behavior for dead clients matches today withinclient_live_ttl_secof new leader boot; no live-client regression on failover; no other regressions.Coordination: RFC #1920 author for future HA-durability follow-up.
Backward Compatibility
GetReplicaList,NotifyOffloadSuccess,ReMountSegmentunchanged.MountLocalDiskSegmentgains optional request fields and richer response. Old clients omitting them get fresh-mount only (no adoption path).LocalDiskDescriptor:stateanddisconnect_grace_expiryare in-memory only, not serialized; YLT_REFL emission unchanged. No snapshot/oplog wire-format changes.client_live_ttl_secis not modified by this RFC. The existing default (10 s in code, 60 s in shipped config) is correct for this design.grace_period_msonUnmountSegmentByIdis unaffected (different mechanism, different trigger, different semantics — see §6).Migration & Rollout
What an existing deployment looks like at upgrade time
Pre-upgrade state:
${storage_path}has data files (Bucket/.metadata files, FilePerKey objects, or OffsetAllocator data blob)..mooncake_local_disk_segment_idmarker file (old client code never wrote it).client_idper client process (today's behavior).Bootstrap rule
Restating §Design.4 in the rollout context: the rule must auto-migrate by default for zero-touch upgrade compatibility, with the strict mode (which catches the "I moved my SSD between hosts" footgun) made opt-in:
The auto-migration path logs a
WARNINGlike:Emits metric
mooncake_client_unmarked_data_auto_migrated_total. Monitoring should alert on this counter going non-zero (it should fire exactly once per host across the upgrade).Compatibility matrix
local_disk_segment_id→ master treats as no-identity → today's behavior on that client (no benefit, no regression)local_disk_segment_idin mount request → today's behavior (no benefit, no regression)Mixed versions are safe. The feature kicks in only when both sides are new. Operators can roll out master and clients independently and in any order.
Recommended rollout sequence
For an HA Mooncake deployment (1 master leader + N standbys + M clients):
mooncake_master_local_disk_replicasshould show stable counts).mooncake_master_disconnect_window_secondshistogram should show p99 < 1s for reattached segments).MC_STORE_REJECT_UNMARKED_DATA=1once all hosts have markers written. This protects against future moved-disk footguns.For non-HA single-master deployments: upgrade master, then roll clients. Same end state.
vLLM-side changes required: none
MooncakeStoreConnectorcallsmooncake.Client(...)via Python bindings. The new behavior (marker file read, auto-migrate, etc.) is internalized inClient::Client()C++ logic. The Python API surface doesn't change. vLLM operators only need to update the Mooncake wheel pinned in their image / requirements file and restart vLLM workers.Caveat: if a vLLM worker creates multiple
Clientinstances sharing the samestorage_path(rare but possible if vLLM uses multiprocessing with shared volumes), only the first should bootstrap the marker. The implementation usesO_CREAT | O_EXCLfile creation to ensure single-writer discipline; secondary callers read the existing marker.Rollback safety
GracefulUnmountSchedulerstate).What operators must NOT do
These caveats belong in the deployment documentation alongside the marker file's location.
Alternatives Considered
A: Keep immediate erasure, rely on ScanMeta. Today's design. Rejected: 30s–5min gap with cross-instance miss; OffsetAllocator-broken; doesn't address #2254.
B: Persistent
client_idonly. Partially adopted as the marker-file identity in §4. Insufficient alone — without delayed deletion, master either keeps dead state forever or erases immediately.C: External membership service. Adds an operational component. Rejected: master is already the membership authority via
ok_client_.D: Per-key TTL instead of per-segment. Rejected: timer-state explosion, no coherent adoption granularity.
E: Connector-level fix in vLLM. Rejected: doesn't help sglang/HiCache/other consumers.
F: Client Graceful Shutdown + ScanMeta. Composes, doesn't replace. Crashes still hit the gap.
G: Deploy with
replica_num >= 2. The architecturally clean answer for cross-instance availability. Pros: works during downtime, survives permanent loss. Cons: 2× SSD + 2× bandwidth, operator-level config, two divergent copies on rejoin. Out of scope but recommended for clusters that need cross-instance availability during downtime. Composes with this RFC for full production-grade configuration.H: Use
DISK(3FS / shared FS) instead ofLOCAL_DISKas the offload tier.DiskReplicaDatahas noclient_id— file_path is independent of any client process, so client restart doesn't affect master metadata. The bug doesn't exist for DISK-backed deployments. Pros: zero engineering work, full cross-instance availability today, survives any client failure. Cons: read latency goes from ~50µs (local SSD + RDMA) to ~ms (3FS metadata + network read). Out of scope but the right answer for deployments where shared-FS latency is acceptable. For hot KV cache, the latency cost typically isn't acceptable; for warm/cold tier, it's fine. Production deployments often use both — LOCAL_DISK hot tier (subject to this bug) + DISK cold tier (not).Test Plan
local_disk_disconnect_test.cpp(new): one test per mechanism —DisconnectedReplicaInvisibleToReader,EvictionSkipsDisconnectedOnlyKeys,SegmentIdAutoReattach,MovedDiskMarkerMismatch,EpochMismatchRejected,MaxAdoptionAttempts,PromotionTaskCancelledOnExpiry,ConcurrentNotifyDuringAdoption.local_disk_segment_id_bootstrap_test.cpp(new): marker read/write, fresh bootstrap, normal restart, moved-disk error case.Extend
pybind_client_test.cpp: kill -9 + restart + auto-reattach end-to-end.high_availability_k8s_test.cppgainsSeededClientTtlClosesFailoverLeak: kill A, trigger master leader failover, assert that A's LOCAL_DISK replicas are hidden from readers withinclient_live_ttl_secof the new leader's boot (via seededclient_ttlexpiry, not via HA-durable DISCONNECTED state).End-to-end reproducer for #2254 in
mooncake-wheel/tests/test_warm_readoption.py: spin master + two clients, populate SSD, kill A, restart with samelocal_disk_segment_id, assert SSD-hit-rate ≥95% within 1s.References
@yzhan1 commented on GitHub (Jun 3, 2026):
@LujhCoconut PTAL. Thanks!
@LujhCoconut commented on GitHub (Jun 3, 2026):
Thanks for this RFC!! The design approach is sound. The main concerns are:
msgpackarrays, so adding fields would crash old masters reading new snapshots. so shipping the non-HA version first is the pragmatic path;client_live_ttl_secfrom 10s to 300s delays DRAM reclamation — a dead client's memory is held for 5 minutes before being freed, which affects all deployments. The SSD retention goal appears to be already covered bydisconnect_grace_period_sec: 600.refcntsare always on memory replicas, not local_disk replicas. When a client dies,
ClientMonitorFunccallsPrepareUnmountSegmentfirst (which invalidates the memory allocator), thenClearInvalidHandles→CleanupStaleHandles, which erases memory replicas viahas_invalid_mem_handle(). Therefcntis destroyed along with the replica object. Theoffloading_tasksentries are also cleaned up when metadata becomes empty.The overall approach looks reasonable to me. It might be worth breaking this into smaller PRs though — that way each piece can be discussed and reviewed in more depth.
@yzhan1 commented on GitHub (Jun 4, 2026):
@LujhCoconut Thanks for the review! Updated the RFC for following changes:
If this looks good I will start working on the PR
@github-actions[bot] commented on GitHub (Sep 2, 2026):
This issue has had no activity for 90 days and will be closed in 7 days if there is no further activity. Please comment or react if it should stay open.
@github-actions[bot] commented on GitHub (Sep 9, 2026):
Closing due to 3 months of inactivity. If this is still relevant, please comment and we can reopen.