[GH-ISSUE #4700] [RFC]: Rethinking the Master Global State Lock: Lifecycle Isolation and Finer-Grained Synchronization #1197

Open
opened 2026-10-29 21:23:50 +00:00 by bsakaguchi · 8 comments
Owner

Originally created by @Icedcoco on GitHub (Oct 15, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4700

Changes proposed

Status: discussion draft. No implementation has been approved. This document is intentionally written to be challenged on GitHub.

The purpose of this RFC is to review the remaining role of snapshot_mutex_, now named master_state_mutex_, after [PR #4406][pr4406] removed the legacy Master snapshot path. Please treat every proposed decomposition as a hypothesis: reviewers are encouraged to identify incorrect assumptions, missing call paths, unsafe interleavings, and simpler designs. The goal of this discussion is to improve the model before implementation begins.

Audit baseline: local commit db969643a00cb91f059acbccce34760ca0dabf23. The main implementation files referenced here match the merged PR #4406 commit 342c8aeece6b4eefb76c6b8b31ccfac8dde07aa3; source links therefore point to that merge commit. Function names and line numbers refer to this baseline and may move later.

1. Proposal and review goals

PR #4406 removed the legacy Master snapshot path, retained the OpLog-backed snapshot, and renamed snapshot_mutex_ to master_state_mutex_. The old snapshot requirement for a global Master freeze is gone, but many guard sites remain. Some of those sites now protect unrelated lifecycle or metadata interactions that accumulated around the original barrier.

The direction proposed here is to give recovery, memory-segment remount, and LOCAL_DISK registration lifecycle explicit protocols; then gradually remove the global shared lock from ordinary RPCs and background scans; and finally decide whether the master_state_mutex_ member can be removed entirely.

Here, “finer grained” means reducing the conflict domain. Unmounting one client's disk or remounting one segment should not automatically block every object operation in the Master. It does not primarily mean reducing the memory occupied by a mutex object or shortening one critical section in isolation.

The current preliminary assessment is deliberately open to correction:

  • There is no evidence yet that one global std::shared_mutex must remain permanently.
  • Removing all guard statements in one sweep would be unsafe with the current object-lifetime and publication model.
  • The hardest issue is ReMountSegment() retaining metadata pointers after releasing shard locks. The next hardest issues are internal concurrency during restore and ordering between LOCAL_DISK deregistration and replica admission.
  • A few narrow guard sites may be independently removable, but most metadata paths need the remount, readiness, and lifecycle questions resolved first.

The first questions for reviewers are:

  1. Does production or embedded mode require restoring an already-serving Master, or can restore be startup-only?
  2. What visibility and atomicity boundary does a multi-segment remount actually promise?
  3. Can the same client UUID be registered again while an older LOCAL_DISK registration is being swept, or must registrations be generation-scoped?

Answers to these questions determine whether the eventual design is a small protocol split or a substantially broader state-publication change. Please also point out if this RFC overstates the protection provided by the current global lock, or if it misses callers that bypass it.

2. Background

2.1 From a snapshot freeze lock to a general state barrier

The old snapshot_mutex_ was introduced with Master metadata persistence and recovery. The basic arrangement was shared locking for ordinary state access and exclusive locking for snapshot-like operations that needed to pause protocol participants. The historical entry point is described in [the Master persistence and recovery commit][history-persistence].

Over time, other workflows began relying on the same barrier:

  • HA recovery and post-recovery segment remount coordinate metadata and allocator reconstruction.
  • Explicit LOCAL_DISK unmount needs to prevent a registration admitted under the old state from landing after the owner sweep, as described in [PR #3316][history-local-disk].
  • Client liveness and asynchronous offboarding introduced client-level lifecycle guards, as described in [PR #2991][history-liveness], while some paths continued to use the global shared lock.
  • The graceful-unmount scheduler once formed an ABBA deadlock with this lock, as fixed in [PR #2486][history-deadlock].

Therefore, keeping the lock after #4406 does not by itself mean that legacy snapshots were incompletely removed. It means that the original snapshot barrier accumulated responsibilities beyond snapshots. The next cleanup should separate those responsibilities instead of assuming they have one common lifetime.

2.2 Relationship to the retained OpLog snapshot

The current batch OpLog snapshot captures state from the HotStandbyService metadata store. It checks cursor consistency at an applied durable-prefix boundary, pauses the standby replication loop, copies metadata in chunks, and releases the capture. That protocol has its own synchronization and lifetime rules and does not acquire the Master's global state lock; see [the standby capture path][source-oplog-capture]. It answers how an applied log state becomes a coherent snapshot. This RFC addresses how the recovered state is accessed at runtime after it has been installed in Master.

This RFC does not propose changing the OpLog snapshot format, replay order, durable prefix, or leadership fencing. Those persistence guarantees remain owned by the OpLog protocol.

The separation under discussion is runtime synchronization for Master in-memory state. The existence of an OpLog snapshot does not imply that the Master-wide lock is required. Conversely, removing the legacy snapshot path does not prove that every historical guard site is now unnecessary.

3. Current state: what the lock actually protects

3.1 Guard sites and existing local synchronization

At the audit baseline, master_service.cpp directly constructs a guard for master_state_mutex_ at 69 sites: 65 shared-lock sites and 4 exclusive-lock sites. This is a static guard-site count, not a function count, and excludes tests and benchmarks that access the lock through test peers. The complete classification appears in Section 8.

Existing local synchronization includes:

Mechanism Responsibility it already provides Guarantee it does not provide automatically
1,024 metadata shard mutexes and accessors Tenant, object, replica, and related task state within one shard Cross-shard publication; validity of a raw pointer after unlocking
client_mutex_ Client-record registry, host information, and fields such as ok_client_ Continued validity of a registration after release; an atomic LOCAL_DISK admission-plus-metadata update
ClientLivenessRecord serving/retaining guards Ordering an operation against client liveness transitions Explicit disk unmount; migration of one segment's allocator
Segment-manager internal locks Segment containers, state, and allocator registrations A remount that spans multiple accesses; synchronized replacement of metadata buffers
SegmentAllocatorRegistration Allocation and buffer availability, allocator binding, and liveness references One consistent commit across the metadata set and allocator import
LocalSsdManager registry, client-lifecycle, stats, and mailbox locks Registry operations and individual manager calls Metadata writes that happen after a manager API returns
Task-manager internal locks Task queues and task state Object and replica lifetime
4,096 object_operation_locks_ entries Same-object ordering for PutStart/UpsertStart Other object entry points, cleanup, or restore as a whole
Quota, group, discarded-replica, and related synchronization Each local data structure and accounting operation Consistent multi-component recovery, rollback, and publication

Relevant definitions are linked from [metadata and lock ordering][source-header], [client liveness][source-liveness], [allocator registration][source-registration], and [LocalSsdManager][source-ssd-header]. Reviewers should correct this inventory if any of these mechanisms has a broader or narrower contract than stated here.

3.2 Shared locking does not serialize ordinary requests

Put, Get, Remove, eviction, and many other paths take the shared lock, but they still run concurrently with one another. Their ordinary data races are mainly handled by shard locks and other local mechanisms.

The global lock's main extra role is to make these shared accesses mutually exclusive with four exclusive workflows. It does not give RemoveAll(), regex scans, or cross-shard queries a globally consistent snapshot of all ordinary requests.

This distinction matters:

  1. A function having a shard accessor does not prove that its outer shared guard is removable; an exclusive workflow may access the same object without taking that shard lock in the same interval.
  2. A function lacking the outer guard is not automatically buggy; it may be fully protected by local locks or may only run before the state is published.

3.3 Four exclusive entry points

Entry point Key operations Preliminary assessment
ReMountSegment(), L841 Collect replicas across shards, import allocators, replace buffers, and clear endpoint unreadiness Current implementation relies on global exclusion; not a direct deletion candidate
RestoreFromStandbyState(), L3748 Install metadata in chunks, restore auxiliary state and quota/weight data, perform repair and rollback Needs complete recovery isolation; may not need a runtime global RW lock
GracefulUnmountSegment(), L2930 Check ownership, close allocation admission, update segment state, and schedule delayed unmount Strong candidate for an independent review of whether exclusive global locking is needed
UnmountLocalDiskSegment(), L8855 Remove a LOCAL_DISK registry entry, release the lock, then sweep replicas owned by that client Admission-to-write ordering must remain; the scope may be reduced to a client or registration

Source entry points: [remount][source-remount], [restore][source-restore], [graceful unmount][source-graceful], and [LOCAL_DISK unmount][source-local-disk].

4. Risks exposed by direct lock removal

4.1 Remount retains raw pointers after releasing shard locks

This is the clearest current memory-safety constraint.

ReMountSegment() walks shards and collects Replica* and ObjectMetadata*. It then releases the shard locks, imports allocator state, and later uses those pointers to call replace_memory_buffer() and grant leases. The current exclusive global lock blocks other protocol-compliant readers and writers during that interval; see [the remount pointer lifetime][source-remount-pointers].

If the shared guard is removed from Remove, eviction, or durable finalize without changing remount, one legal interleaving is:

T1: remount finds Replica* p in shard S and releases S
T2: Remove, eviction, or finalize acquires S and erases or changes the replica set
T1: allocator import finishes and uses p to replace its buffer
    -> p may be invalid: use-after-free or corrupted state

Removing the shared guard from Get is not automatically safe either. Remount later mutates the buffer without reacquiring the metadata shard lock; a reader holding a shard read lock can still race with that replacement.

Working conclusion for discussion: every path that can read or mutate these objects must not be declared removable merely because it has an accessor. First either eliminate remount's unlocked raw-pointer interval or retain an equivalent exclusion protocol during migration.

The global lock is not a formal proof that every metadata access in the repository is covered. Bypassing helpers and callbacks still need auditing. The remount observation should not be broadened into a claim that the current implementation is otherwise race-free.

4.2 Read paths also access mutable state outside the shard

TryGetReadableReplicaDescriptor() reads invalid_replica_endpoints_; restore replaces this set and remount erases entries from it. The set has no independent mutex at present; see [the readability check][source-readability].

This affects more than GetReplicaList*. ExistKeyImpl(), BatchExistKeyImpl(), and GetAllKeys() reach the same check through HasReadableReplica(). Eviction, Copy/Move, and drain scans have similar visibility decisions.

Removing their shared guards without changing ownership or synchronization would permit concurrent access to an unordered_set, which is a C++ data race. A small lock for the set would solve container safety but would not solve the replica-buffer publication problem above.

4.3 RPC gating is not internal-thread gating during restore

The production supervisor restores a candidate Master before registering or exposing its RPC service; see [the serving gate][source-supervisor]. This is a useful structure for eliminating a runtime global lock.

However, MasterService starts eviction, client monitoring, task cleanup, offboarding, replica cleanup, job dispatch, and dynamic-replication workers during construction; see [the worker startup path][source-workers]. Restore itself may install metadata in chunks, rebuild quota state, replace auxiliary state, and perform durable repair.

Removing only the exclusive restore guard could allow:

  • a background scan to observe only part of the restored chunks;
  • quota scan, rebuild, and publication to interleave with object cleanup or writes;
  • cleanup to make decisions before endpoint or liveness state is complete; and
  • rollback to race with concurrent creation, modification, or deletion.

The fact that a worker is usually idle is not a synchronization contract. Tests, embedded callers, and future entry points may invoke restore directly.

The first design question should therefore be whether restore is supported only for an unpublished, not-yet-serving instance. If yes, startup ordering or a one-time activation barrier can replace per-request participation in a global RW lock.

4.4 LOCAL_DISK has a check-to-write admission window

NotifyOffloadSuccess() and AddReplicaForRetainedClient() currently perform the following while holding the shared state lock:

  1. Check that the client is still registered as LOCAL_DISK.
  2. Acquire the metadata shard lock.
  3. Add or update the LOCAL_DISK replica.

UnmountLocalDiskSegment() removes the registry entry under exclusive locking, releases the global lock, and then performs an owner-targeted sweep. The exclusive removal waits for prior shared admissions to finish; subsequent admission checks fail. The sweep does not need to hold the global exclusive lock for its entire duration if that admission ordering is preserved.

Without an equivalent protocol, this interleaving is possible:

T1: Notify checks that the client is mounted, then waits for shard S
T2: Unmount removes the client from the registry
T2: the sweep visits S before T1 has written the replica
T1: T1 obtains S and writes the replica
    -> an old-owner replica remains visible after unmount completes

LocalSsdManager already has a per-record lifecycle lock, and deregistration waits for manager operations that have acquired the record. But HasMountedLocalDiskSegment() calls GetUsage(), and that internal guard ends when GetUsage() returns; it does not cover a later metadata write. See [the manager implementation][source-ssd-impl] and [replica admission][source-add-replica].

ClientLivenessRecord guards protect client liveness. Explicit disk unmount does not currently acquire the same guard, and disk unmount is not equivalent to taking the entire client offline. Those lifetimes should not be conflated without an explicit contract.

4.5 Same-UUID remount needs an explicit contract

The owner-targeted sweep avoids deleting objects registered by a different client while the sweep is in progress; an existing [interleaving test][source-ssd-test] covers that shape.

The source still permits a different question:

Unmount(A): deregister A and release the exclusive lock
Mount(A):   register the same UUID again
Notify(A):  admit a LOCAL_DISK replica for the new registration
Unmount(A): the old sweep deletes replicas by owner == A

The existing test uses different UUIDs for the leaving and late clients, so it does not establish same-UUID safety. Normal FileStorage drain uses its own latch and lock to constrain heartbeat remounts; see [the client drain path][source-file-drain]. This should therefore be described as a Master API contract and validation question, not as a reproduced production failure.

Two possible contracts are:

  • prohibit a new registration for that UUID until the old sweep completes; or
  • allow overlap, but give registry entries, replicas, and cleanup work distinct registration identities or generations.

4.6 Does graceful unmount really need global exclusivity?

We should not assume that removing this exclusive guard requires adding another global or segment lifecycle mutex.

The current function already holds ScopedSegmentAccess across ownership validation, PrepareGracefulUnmountSegment(), and scheduler Schedule(). The segment path removes the allocator from the allocatable set and calls SetAllocatable(false). SegmentAllocatorRegistration::Allocate() checks availability and liveness before and after allocation, and the allocator reference is accessed through atomic shared-pointer operations. See [segment state transition][source-segment] and [allocator registration][source-registration].

That makes this a useful independent review item: can “close admission for new allocations” be the linearization point, allowing already-admitted requests to finish under the existing segment and registration state, without blocking unrelated metadata?

The following cases still need proof:

  • a request has copied an allocator reference and tries to commit after the transition;
  • a request passed the check but has not returned or committed;
  • remount, immediate unmount, expired-client offboarding, and timer callbacks overlap; and
  • an old deadline acts on a newly reused segment identity.

The scheduler already releases its internal mutex before invoking the callback; see [DeadlineScheduler][source-scheduler]. A redesign must preserve that property and avoid a new scheduler/segment/client lock cycle. The historical ABBA fix is a design constraint, not proof that the current code always deadlocks.

4.7 Scans, accounting, and OpLog ordering cannot be delegated to one lock

Cross-shard scans already permit ordinary requests to interleave. A finer-grained design should preserve or add “reacquire the shard, then revalidate object, replica ID, version, and state” checks instead of replacing every scan with a new global lock.

The following points need explicit review:

  • Are the retaining-client sets captured by ClearInvalidHandles(), Put/Upsert, and BatchRemove stale? Mount takes the same shared lock today, so the old global lock does not make a one-time UUID snapshot complete for clients mounted later. A replica-bound liveness record may be more accurate than one copied UUID set, but compatibility with restored state must be checked.
  • Do quota, SSD used-bytes, and capacity metrics charge the correct object or registration? Atomic increments and decrements make individual counters safe, but do not prevent an old-generation decrement from being applied to a new generation found by UUID.
  • Must durable finalize retain identity checks against object deletion and recreation? The old shared lock never serialized two ordinary metadata operations, so existing ABA behavior should not automatically be attributed to lock removal.
  • If online restore is supported, must old callbacks be drained, cancelled, or tagged with a service/state generation? Startup-only restore reduces this problem substantially.
  • Are callbacks, workers, or durability completion ever awaited while holding a lock they need to make progress? Such waits can create new cycles.

4.8 Removing this lock may still leave global waits

ReMountSegment() currently holds client_mutex_ exclusively for the main recovery interval in addition to master_state_mutex_. FindClientRecord(), Put/Upsert, and other paths acquire the same client mutex. Removing only master_state_mutex_ can therefore leave unrelated clients waiting behind remount. RestoreFromStandbyState() also holds both locks, although startup-only restore can remove its steady-state cost through lifecycle isolation.

This does not mean the entire client registry must be redesigned in this RFC. It means two benefits must be measured separately: removing per-request participation in the global state mutex, and reducing lifecycle operations' blocking domain. To achieve the second benefit, remount may need to acquire stable liveness/resource identities, release the registry lock, and revalidate at commit; see [record lookup][source-client-record]. That would open remount/offboarding interleavings that are currently serialized, so ok_client_, client_host_id_, standby_allocator_keepalive_, and standby_accounted_memory_bytes_ need explicit ownership rules.

5. Goals and boundaries

5.1 Goals

  1. Ordinary object access should not unconditionally modify the same global shared mutex state.
  2. Lifecycle operations should primarily affect the relevant client, segment, registration, or metadata shard.
  3. Data-structure safety, object lifetime, and publication order should each have an explicit owner.
  4. Existing contracts for readability, allocation safety, client liveness, leases, quota, and OpLog durability must remain intact.
  5. Each removal step must explain which dependency it eliminates and provide targeted interleaving tests.

5.2 Explicitly out of scope for this RFC

  • Redesigning the OpLog snapshot format or persistence protocol.
  • Converting all metadata containers to lock-free or RCU structures.
  • Adding a new global-consistent-scan guarantee merely to remove the old lock.
  • Simultaneously rewriting every tenant, group, task, and allocator subsystem.
  • Claiming a specific performance improvement before measurements exist.

6. Alternatives and trade-offs

Option Approach Potential benefit Cost and limitation
A: Keep a smaller global barrier Remove independently justified task/mailbox sites; use client-level LOCAL_DISK synchronization; keep a global barrier around restore/remount for now Lower immediate risk and easier staged review Ordinary metadata hot paths may still touch a global lock; this is not the final goal
B: Lifecycle isolation plus local publication Complete restore before publication; remount through segment state/generation and shard-level rebinding; use a per-client or per-registration LOCAL_DISK protocol Could remove the global lock completely and reduce conflict scope Requires solving raw pointers, atomic publication, rollback, lock order, and compatibility contracts
C: Versioned state or broad RCU Build a complete new state and publish an immutable view atomically May simplify read paths for a suitable data model High change surface for metadata mutation, allocator reclamation, and multi-component updates; not recommended as the starting point

The suggested discussion direction is B, with A as a conservative intermediate. This is not a commitment to introduce every proposed generation or state type. The community should first decide which invariants require them.

Renaming master_state_mutex_ to recovery_mutex_ while continuing to make ordinary requests acquire a shared guard would preserve the global hot-path coupling under a new name.

7. Preliminary design for discussion

7.1 Restore: turn concurrency into startup ordering where possible

The first proposal is an explicit Master lifecycle:

stateDiagram-v2
    [*] --> Constructed
    Constructed --> Recovering: HA promotion
    Constructed --> Ready: non-HA initialization
    Recovering --> Ready: restore and durable repair succeed
    Recovering --> Failed: restore fails
    Ready --> Serving: activate workers and expose service
    Serving --> Stopping: leadership loss or shutdown
    Failed --> [*]
    Stopping --> [*]

The absence of a Serving -> Recovering edge is a proposed constraint, not an established API guarantee.

Possible rules:

  • Construct components, but keep workers that mutate business metadata paused until a one-time activation. Alternatively, start threads that wait on an activation gate before doing useful work.
  • Finish metadata, quota/weight, endpoint state, and required durable repair before opening business workers and RPC/admin mutation entry points.
  • An OpLog writer or other infrastructure may need to run during recovery for durable repair. Separate recovery infrastructure from workers that process live business state instead of delaying every thread blindly.
  • Destroy a failed candidate rather than exposing it as Serving. Stop must wake workers waiting for activation.
  • Give tests and embedded mode an explicit initialization/activation API so they do not depend on constructors starting every worker immediately.

This lets steady-state RPCs avoid a shared lock whose only purpose is protecting a phase that occurs before publication.

If online restore is required, use a separate protocol: close admission, drain requests and callbacks that have entered, then restore and publish. A single atomic restoring flag is insufficient because a request can pass the check and remain in its critical interval. Waiting for drain while holding a callback-needed lock is also unsafe. Online restore should be a separately approved scope because it substantially expands the design.

7.2 Remount: eliminate address escape before choosing segment granularity

Consider remount as three logical phases: prepare, rebind, and publish. Names and exact interfaces are intentionally left open for review.

Prepare: identify the segment registration being recovered; prevent new allocation and premature reads; under shard locks collect stable identities such as tenant/key, replica ID, descriptor, and versions instead of retaining Replica* after unlock.

Rebind: import allocations into the prepared allocator. Before changing metadata, reacquire the owning shard write lock, look up the object, and validate replica ID, old descriptor/registration, and version. If the object was deleted or changed, release the unused imported allocation or perform a bounded retry/failure; do not commit through an old pointer.

Publish: publish segment allocatable/readable state only after buffers, allocator binding, accounting, and readiness satisfy the selected invariant. Keep the old allocator alive until dependent buffers have moved or been safely released.

Questions that must be answered before implementation:

  1. Between descriptor collection and commit, which object deletion, rewrite, or new replica operations are allowed? How are missing entries and unused imported allocations handled?
  2. If an object spans multiple segments, may it be temporarily partially rebound? May reads return only replicas already valid?
  3. For a request carrying multiple segments, must publication be batch-atomic, or can segments succeed independently with retryable failure?
  4. On an error halfway through, is the contract full rollback, affected segments remaining invisible, or bounded retry?
  5. Are segment name, endpoint, and UUID sufficient to identify a physical-resource generation, or is one common generation needed?

Adding a per-segment mutex alone does not answer these questions. Remove and eviction may not know all segments involved before entering their shard, and may not acquire the same new lock. If that mutex is expected to preserve object lifetime, every relevant path must participate.

A conservative transition can be evaluated: hold all relevant shard write locks in a fixed order across the rare remount pointer-use interval. If the shard set cannot be proven stable, temporarily holding all shards may remove the hot-path global mutex while still causing a broad pause. That is not segment-level isolation, and the order between multiple shards, client guards, and segment locks still requires proof.

If a per-segment transition state or generation is selected, a candidate state machine is Recovering -> Ready -> Retiring. This RFC does not require those exact enum names. Reusing existing registration/segment state is preferable to maintaining two overlapping state machines.

7.3 Endpoint readability: put synchronization with the owner of the state

For invalid_replica_endpoints_, compare:

Choice Advantage Review concern
A small independent RW lock Directly fixes container races Read paths still touch a shared lock; lock ordering with shard locks must be proven
Immutable endpoint set with atomic view publication Complete views are easy for readers; suitable for read-mostly updates Shared-pointer lifetime and update cost need measurement
Readiness owned by a segment/registration object State ownership follows the resource and can be generation-scoped Must handle aliases, old generations, restore binding, and multi-segment publication

The current preference is to make readiness part of segment/registration state eventually, while allowing the first step to use one of the smaller mechanisms. Publishing readiness must follow correct buffer and allocator binding. Protecting the set itself is not a complete remount protocol.

7.4 LOCAL_DISK: organize admission, deregistration, and sweep by registration

The invariant to preserve is:

A replica admission is either accepted into a still-valid registration and included in that registration's retirement work, or rejected. A sweep for an old registration must not delete replicas belonging to a new registration.

Two routes are worth comparing.

D1: serialize disk lifecycle per client. Mount, admission, and unmount share a stable per-client disk gate. Unmount closes admission, waits for already-admitted operations, sweeps, and only then allows a new mount for that UUID.

It may be possible to reuse ClientLivenessRecord guards, since Mount and Notify already use related liveness checks. That would bind disk cleanup to heartbeat, memory operations, and liveness transitions, however. The impact on liveness latency, record cleanup, and long sweeps must be measured; reusing an existing guard is not automatically free.

A dedicated per-client gate needs stable ownership. Removing a registry record must not allow a same-UUID remount to create a different gate while the old sweep still runs. A tombstone/retiring entry or independent gate owner may be required.

D2: explicit registration handle or generation. Extend the existing LocalSsdManager::ClientAccess idea so a business operation receives a handle that spans admission, metadata write, and required accounting. Unmount marks that registration unavailable, then waits for in-flight handles. If a new generation may be created concurrently, the old sweep matches registration identity/generation rather than UUID alone.

Implementation questions include:

  • Do not hold the registry container lock while waiting for a shard or sweeping the metadata table.
  • ClientAccess is currently private, and its lifecycle guard covers one manager method. Exposing a business handle requires a complete API rather than returning GetUsage() and assuming the guard survives.
  • Holding a handle and then calling a helper that reacquires the same lifecycle lock can deadlock or self-block. Accounting and mailbox operations may need handle-aware entry points.
  • If the disk gate is acquired before a shard, cleanup helpers called while holding a shard must not wait for that gate in the reverse order.
  • A Master-local generation distinguishes admitted local operations. Rejecting an old client RPC that arrives after a new mount may additionally require a wire/task token, which could affect protocol compatibility.
  • Durable finalize may occur after sweep returns. Used-byte decrements must target the old registration rather than looking up the UUID again and charging a new one.

The first policy question is whether same-UUID parallel remount is needed at all. If it is forbidden, D1 is simpler. If it is allowed, D2 gives a more complete ownership model. Neither route should put unrelated clients' ordinary Get/Put operations behind a disk lifecycle gate.

7.5 Graceful unmount: start from existing segment synchronization

Treat this as an independent review item. First determine whether the current segment mutex, registration availability, and scheduler already express the required semantics.

A possible contract is: after allocation admission closes, new allocation fails or chooses another segment; operations admitted before the transition may finish under existing lifetime rules; physical unmount still follows the grace period and buffer validity. If the product requires all in-flight allocations to drain before the RPC returns, add an explicit in-flight protocol instead of relying on an incidental global lock.

Even if this entry point's exclusive global guard is removed, it must not change the same segment midway through remount allocator import and publication. A local transition check or protocol may be sufficient; unrelated metadata should not continue to pay for a global barrier.

7.6 Business and background paths must obey the same invariant

Ordinary metadata operations continue to use shard accessors. Object-local tasks, quota ledgers, and replica state changes remain under their owning shard or subsystem synchronization.

Eviction, offboarding, promotion, dynamic replication, drain jobs, and durable finalize must not become loopholes around the new protocol. Scans should retain stable identities and revalidate at commit; registration-bearing operations must also validate lifecycle identity.

Avoid hiding a new global gate inside MetadataAccessor to “cover all callers automatically”. That would recouple all shards and may break nested lock ordering.

8. Guard-site classification: what might be removed first

The table below covers all 69 direct guard sites in the audit baseline. The line number is the start of the guard declaration. The categories mean:

  • D — independent deletion candidate: the body appears to be protected by local synchronization and has no identified need to participate in metadata/remount global exclusion. It still requires behavior and concurrency validation.
  • M — metadata/remount prerequisite: resolve raw pointers, buffer publication, and readiness first; online restore also requires recovery isolation.
  • L — LOCAL_DISK prerequisite: preserve registration admission, deregistration, or accounting semantics.
  • C — lifecycle entry point: independently prove state transitions and adjacent interleavings.

8.1 Four high-priority independent candidates

Location Existing local protection Why global shared locking may be removable, and boundary
PollRemoveAll, L8933 LocalSsdManager registry/client access and mailbox mutex Consumes a mailbox flag only and does not access metadata; preserve unmount return semantics
PromotionObjectHeartbeat, L10394 Client serving guard, LocalSsdManager, and mailbox Takes tasks only; later task APIs still validate object, replica, and state
FetchTasks, L13317 Client serving guard and task-manager write access Pops tasks without changing replicas; should not freeze all metadata for this operation
MarkTaskToComplete, L13337 Client retaining guard and task-manager write access Updates task-manager state and does not execute copy/move completion itself

These are D candidates, not approved deletions. If online restore later replaces these components, their service-admission contract must be revisited.

8.2 Other shared sites: remove by dependency

Location and shared-lock line Category Prerequisite or review focus
MountSegment: L712 C Existing registry/client locks; interleaving with remount and graceful unmount for the same segment
Four durable-finalize paths: L1739, 1832, 1854, 1893 M May delete or move replicas and invalidate remount-retained pointers; check late callbacks and identity
ClearInvalidHandles: L2501 M Same pointer-lifetime issue; separately verify retaining-client snapshot freshness
ProcessClientOffboardingJob: L2662 M, L, C OFFLINE must block new guards; coordinate segment retirement, explicit disk unmount, and callbacks
TaskCleanupThreadFunc: L2851 M Also cleans soft pins and dynamic-replication state, so it is not task-manager-only
UnmountSegment, UnmountNoFSegment: L2869, 2975 M, C Prepare/cleanup/commit, buffer invalidation, and remount ordering
ExistKeyImpl, BatchExistKeyImpl, GetAllKeys: L3027, 3084, 3127 M Reach readability checks involving endpoint state and remount buffers
BatchReplicaClear: L4382 M Modifies/deletes replicas; shard locking alone is not enough while remount retains pointers
GetReplicaListByRegex: L4676 M Readability and leases; the scan is not a global snapshot today
GetReplicaList, GetReplicaListForAdmin: L4709, 4812 M Readiness access; ordinary Get may also trigger promotion/dynamic replication
BatchGetReplicaList and admin version: L4870, 5026 M Revalidate each shard read and later candidate submission
PutStart, UpsertStart: L5475, 6163 M, C Allocation admission, metadata commit, stale cleanup; object stripes do not cover all paths
PutEnd, PutRevoke: L5588, 5892 M Replica update and reclamation
AddReplicaForRetainedClient: L5787 M, L Preserve the check-to-metadata-write interval for disk registration
CopyStart/End/Revoke: L6833, 7045, 7226 M, C Source readiness, target allocation, object task state, and refcounts
MoveStart/End/Revoke: L7305, 7451, 7645 M, C Same as Copy; verify source/target lock order
Remove, RemoveByRegex: L7704, 7780 M May invalidate remount-retained pointers
The two RemoveAll paths: L7878, 8023 M, L Per-shard cleanup, disk notification, and new-registration contract
BatchRemove: L8164 M Retaining-client snapshot and per-shard deletion
Bucket/shard DFS eviction: L8453, 8636 M Preserve allocator prepare/resolve and shard revalidation; prove multi-shard order
MountLocalDiskSegment: L8802 L Internal registry insertion is protected; same-UUID old-sweep semantics remain to be designed
OffloadObjectHeartbeat: L8896 M, L Disabling offload also changes object task/refcount state
ReportSsdCapacity: L8949 L Relate internal capacity, external metric deltas, deregistration, and generations
NotifyOffloadSuccess NACK: L9004 M Cleans task/refcount state; not adding a replica does not remove remount dependencies
NotifyOffloadSuccess success branch: L9029 M, L Preserve admission-to-write ordering; audit used-byte updates outside the interval
RunPromotionCandidateRetry: L9493, 9569 M Two-phase scan/commit; use stable identities and revalidate
DynamicReplicationAdmissionThreadFunc, SubmitReplicaActionProposal: L9787, 10035 M Readiness, object state, and task admission
PromotionAllocStart, NotifyPromotionSuccess/Failure: L10417, 10552, 10680 M, C Target allocation, source/target replica lifetime, and task accounting
Cleanup interval in EvictionThreadFunc: L10847 M Walks shards and handles expired processing replicas
Tenant eviction, BatchEvict, NoFBatchEvict: L11242, 11888, 12427 M Pointer/descriptor lifetime, readiness, group semantics, and parallel subscans
ClientMonitorFunc, TryUnmountNoFSegmentByHeartbeat: L12651, 12762 C, M Client/NoF retirement and metadata cleanup
CreateCopyTask: L13075, 13134; CreateMoveTask: L13194, 13263 M Rediscover, reacquire guard/shard, and revalidate; preserve two-phase ordering
ScheduleDrainJobTasks, MaybeCompleteDrainJob: L13630, 13743 M Cross-shard results are candidates only; revalidate at task admission/completion

The table contains 61 shared sites here plus the four D sites above, for 65 shared sites. The four exclusive sites are listed in Section 3.3; they are protocol entry points rather than general deletion candidates.

The previous broad claim “this only touches metadata, so its shared guard can be deleted” is not sufficient. In particular, existence queries, GetAllKeys, durable finalize, BatchReplicaClear, and the entire TaskCleanupThreadFunc need prerequisites and must not be placed on an unconditional deletion list.

9. Lock ordering and publication constraints

The current header documents ordering among the client lock, tenant-policy mutex, master-state lock, metadata shard, quota recompute, segment/quota table, and soft-pin index. A new design also involves liveness transitions, LOCAL_DISK lifecycle gates, mailbox state, and scheduler callbacks. Renaming one line in the old table is not enough.

At minimum, an implementation review should require:

  1. A fixed order for acquiring multiple shards, including group and DFS operations.
  2. No path that holds a shard while waiting for a transition guard that may turn around and wait for that shard. Use discover, release, reacquire in the prescribed order, and revalidate where necessary.
  3. No LOCAL_DISK registry-container lock held while waiting for a shard or sweeping the metadata table.
  4. No Master callback invoked while the scheduler mutex is held.
  5. No callback, worker, or OpLog completion awaited while holding a lock required for that completion.
  6. Atomic readiness/generation publication does not replace synchronization for ordinary containers; readers still need valid ownership and a matching state view.
  7. Cleanup, timers, accounting, and callbacks for reused UUIDs or names must identify the intended resource generation.

These are review constraints, not a proven deadlock-free lock graph. A complete dependency graph should be added after the community selects a design and its call paths.

10. Validation plan: correctness before performance claims

10.1 Targeted interleavings

Use barriers and test hooks to force specific interleavings instead of relying only on random stress:

Scenario Expected property to verify
Remove, Upsert, eviction, or durable finalize for the same key after remount collection No invalid pointer; revalidation leads to correct commit, skip, or failure; no leak
Get/Exist/BatchGet and Copy/Move during remount rebinding No descriptor becomes visible before it is ready; no buffer race
Mid-flight failure in a multi-segment remount Allocator, buffer, readiness, quota, and metrics obey the selected rollback/failure-isolation contract
Graceful unmount after an allocator snapshot has been obtained Allocation and commit obey the agreed admission linearization
Notify passes mount check but has not acquired a shard when Unmount sweeps No new visible replica lands under the old registration
Different UUID mounts during deregistration and sweep New owner's objects remain; current test continues to pass
Same UUID mount and late RPC/callback during deregistration and sweep Contract rejects, waits, or distinguishes generations; old sweep does not delete new data
Unmount, capacity reports, used-byte updates, and finalize overlap Accounting does not cross generations; no negative or duplicate release
Restore stalls during chunk installation or accounting publication RPC/admin mutation and workers do not observe half-restored state
Restore failure, leadership loss, Stop, and callbacks overlap Failed instance is not exposed; no drain/join deadlock
TTL offboarding, explicit disk unmount, and remount overlap New work cannot bypass OFFLINE; record cleanup and resource generation agree
RemoveAll, group eviction, drain, and ordinary writes overlap Existing API behavior remains; per-shard revalidation is not lost

TSAN can supplement race detection, and ASAN plus targeted stress can expose pointer-lifetime bugs. Neither replaces assertions for stale registrations, duplicate allocations, or accounting order. Tests should cover HA and non-HA, different memory allocators, and the affected NoF/CXL branches.

10.2 Performance hypotheses and measurements

The expected benefits are lower cache-line contention from shared lock state and less tail latency when a lifecycle operation runs. Both are hypotheses. Contention may move to client, shard, allocation, or quota locks.

Compare the current baseline, an intermediate design, and the final design on:

  • Get/Exist/Put/Remove throughput and p50/p99/p999 latency across many shards;
  • unrelated-client latency and throughput during one client's disk unmount;
  • related and unrelated shard pauses during a large object remount;
  • wait time when long eviction or RemoveAll scans overlap lifecycle operations;
  • conflicts on new gates, generation retries, temporary imported-resource usage, and reclamation; and
  • hot-key, same-client, and cross-NUMA load shapes.

The repository already has a [batch-eviction benchmark][source-benchmark] that observes global exclusive-lock waits through a test peer. After lock removal, translate such implementation-specific metrics into user-visible latency and isolation metrics rather than deleting regression coverage simply because the old mutex no longer exists.

11. Suggested discussion and delivery boundaries

The following decomposition is offered for community selection, not as an approved implementation plan:

  1. Confirm contracts and measure the baseline. Decide restore lifetime, remount atomicity, and same-UUID mount semantics; measure the actual global-lock cost.
  2. Review independently removable sites. Start with the four D candidates in Section 8.1 and analyze graceful unmount separately. Each change should explain why it no longer needs to block the four exclusive workflows.
  3. Isolate LOCAL_DISK. Choose D1 or D2; limit admission/deregistration ordering to the relevant client or registration; add sweep, metric, and finalize boundary tests.
  4. Isolate recovery. Define initialization/activation for an unpublished instance and identify which writers or callbacks may run during recovery.
  5. Refactor remount and readiness. Remove the unlocked raw-pointer dependency and define allocator import, buffer rebinding, publication, and failure recovery.
  6. Move ordinary metadata paths out of the barrier. Remove shared guards by category, review helper contracts, and verify that background and synchronous paths obey the same invariants.
  7. Remove the last member and test coupling. Update lock-order documentation, test peers, HA/tenant-quota/SSD tests, and benchmarks that directly reference the lock.

Steps 3 and 4 can be designed independently. Step 6 depends on Steps 4 and 5; L-category paths also depend on Step 3. Shared guards should not be removed in bulk before those prerequisites are resolved. Every surviving exclusive workflow must remain correct during the mixed old/new transition.

Each implementation PR should include its own correctness argument and validation results. Avoid one large PR that simultaneously changes startup APIs, registration identity, remount, wire protocol, and all guard sites. If the community decides to retain a transitional barrier only for necessary cases, document its remaining participants and its exit criteria.

12. Questions for reviewers and maintainers

The central request of this RFC is a critical review. Please comment with concrete counterexamples, source references, or simpler alternatives, especially where the proposed design is over-engineered or where the current analysis is too optimistic.

  1. Restore boundary: Is there a production or embedded use case that must restore an already-serving Master? If not, is a startup-only API acceptable?
  2. Remount atomicity: Must all segments in one batch become visible together? What read result and retry behavior are allowed for an object whose replicas are only partially rebound?
  3. Remount transition: Is a rare pause that locks relevant or all shards acceptable as an intermediate step to remove the global RW lock from ordinary paths, or is true segment-level isolation required immediately?
  4. Graceful-unmount linearization: After new allocation admission closes, may already-admitted operations finish? Are current registration checks enough, or is explicit in-flight drain required?
  5. LOCAL_DISK identity: May Mount and Unmount for one UUID overlap? Should the first implementation serialize per client, or introduce registration generations? Must late RPCs from an old generation be rejected?
  6. Readiness ownership: Should endpoint readiness remain an independent set, use an immutable view, or move into segment registration? Which aliases and restore-compatibility constraints are mandatory?
  7. Persistence and asynchronous completion: Which callbacks, workers, or tests truly depend on global exclusion? Which existing identity/version fields can be reused, and which semantics are not yet defined?
  8. Lock-order review: Which call path or helper has been omitted from the proposed ordering? Are there shard-to-manager or manager-to-shard paths that make D1/D2 unsafe?
  9. Performance priority: Is the main target steady-state throughput, unmount tail latency, or HA promotion/remount pause? What production telemetry or benchmark should decide between A and B?
  10. Analysis errors: Which statement in this RFC is factually wrong, too strong, or based on a test that does not cover the claimed interleaving? Please challenge the four D candidates in particular.

The desired outcome of this discussion is not immediate approval. It is a shared list of invariants, rejected assumptions, and an implementation boundary that developers can defend before coding starts.


This draft is based on the specified source commit, repository history, and existing tests. It does not modify runtime code and does not claim that a lock-removal experiment or performance test has been run. The interleavings are code-derived hypotheses and are labelled as validation targets. This RFC is not a proof that the current implementation or any proposal is free of all concurrency bugs.

This RFC draft was prepared with AI assistance. Any feedback, error corrections, or architectural suggestions are warmly welcome, and I will incorporate them with an open mind so we can work together toward clean, efficient, and robust code.

Before submitting a new issue...

  • Make sure you already searched for relevant issues and read the documentation
Originally created by @Icedcoco on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4700 ### Changes proposed > **Status: discussion draft.** No implementation has been approved. This document is intentionally written to be challenged on GitHub. > > The purpose of this RFC is to review the remaining role of `snapshot_mutex_`, now named `master_state_mutex_`, after [PR #4406][pr4406] removed the legacy Master snapshot path. Please treat every proposed decomposition as a hypothesis: reviewers are encouraged to identify incorrect assumptions, missing call paths, unsafe interleavings, and simpler designs. The goal of this discussion is to improve the model before implementation begins. > > Audit baseline: local commit `db969643a00cb91f059acbccce34760ca0dabf23`. The main implementation files referenced here match the merged PR #4406 commit `342c8aeece6b4eefb76c6b8b31ccfac8dde07aa3`; source links therefore point to that merge commit. Function names and line numbers refer to this baseline and may move later. ## 1. Proposal and review goals PR #4406 removed the legacy Master snapshot path, retained the OpLog-backed snapshot, and renamed `snapshot_mutex_` to `master_state_mutex_`. The old snapshot requirement for a global Master freeze is gone, but many guard sites remain. Some of those sites now protect unrelated lifecycle or metadata interactions that accumulated around the original barrier. The direction proposed here is to give recovery, memory-segment remount, and LOCAL_DISK registration lifecycle explicit protocols; then gradually remove the global shared lock from ordinary RPCs and background scans; and finally decide whether the `master_state_mutex_` member can be removed entirely. Here, “finer grained” means reducing the conflict domain. Unmounting one client's disk or remounting one segment should not automatically block every object operation in the Master. It does not primarily mean reducing the memory occupied by a mutex object or shortening one critical section in isolation. The current preliminary assessment is deliberately open to correction: - There is no evidence yet that one global `std::shared_mutex` must remain permanently. - Removing all guard statements in one sweep would be unsafe with the current object-lifetime and publication model. - The hardest issue is `ReMountSegment()` retaining metadata pointers after releasing shard locks. The next hardest issues are internal concurrency during restore and ordering between LOCAL_DISK deregistration and replica admission. - A few narrow guard sites may be independently removable, but most metadata paths need the remount, readiness, and lifecycle questions resolved first. The first questions for reviewers are: 1. Does production or embedded mode require restoring an already-serving Master, or can restore be startup-only? 2. What visibility and atomicity boundary does a multi-segment remount actually promise? 3. Can the same client UUID be registered again while an older LOCAL_DISK registration is being swept, or must registrations be generation-scoped? Answers to these questions determine whether the eventual design is a small protocol split or a substantially broader state-publication change. Please also point out if this RFC overstates the protection provided by the current global lock, or if it misses callers that bypass it. ## 2. Background ### 2.1 From a snapshot freeze lock to a general state barrier The old `snapshot_mutex_` was introduced with Master metadata persistence and recovery. The basic arrangement was shared locking for ordinary state access and exclusive locking for snapshot-like operations that needed to pause protocol participants. The historical entry point is described in [the Master persistence and recovery commit][history-persistence]. Over time, other workflows began relying on the same barrier: - HA recovery and post-recovery segment remount coordinate metadata and allocator reconstruction. - Explicit LOCAL_DISK unmount needs to prevent a registration admitted under the old state from landing after the owner sweep, as described in [PR #3316][history-local-disk]. - Client liveness and asynchronous offboarding introduced client-level lifecycle guards, as described in [PR #2991][history-liveness], while some paths continued to use the global shared lock. - The graceful-unmount scheduler once formed an ABBA deadlock with this lock, as fixed in [PR #2486][history-deadlock]. Therefore, keeping the lock after #4406 does not by itself mean that legacy snapshots were incompletely removed. It means that the original snapshot barrier accumulated responsibilities beyond snapshots. The next cleanup should separate those responsibilities instead of assuming they have one common lifetime. ### 2.2 Relationship to the retained OpLog snapshot The current batch OpLog snapshot captures state from the `HotStandbyService` metadata store. It checks cursor consistency at an applied durable-prefix boundary, pauses the standby replication loop, copies metadata in chunks, and releases the capture. That protocol has its own synchronization and lifetime rules and does not acquire the Master's global state lock; see [the standby capture path][source-oplog-capture]. It answers how an applied log state becomes a coherent snapshot. This RFC addresses how the recovered state is accessed at runtime after it has been installed in Master. This RFC does not propose changing the OpLog snapshot format, replay order, durable prefix, or leadership fencing. Those persistence guarantees remain owned by the OpLog protocol. The separation under discussion is **runtime synchronization for Master in-memory state**. The existence of an OpLog snapshot does not imply that the Master-wide lock is required. Conversely, removing the legacy snapshot path does not prove that every historical guard site is now unnecessary. ## 3. Current state: what the lock actually protects ### 3.1 Guard sites and existing local synchronization At the audit baseline, `master_service.cpp` directly constructs a guard for `master_state_mutex_` at **69 sites: 65 shared-lock sites and 4 exclusive-lock sites**. This is a static guard-site count, not a function count, and excludes tests and benchmarks that access the lock through test peers. The complete classification appears in Section 8. Existing local synchronization includes: | Mechanism | Responsibility it already provides | Guarantee it does not provide automatically | | --- | --- | --- | | 1,024 metadata shard mutexes and accessors | Tenant, object, replica, and related task state within one shard | Cross-shard publication; validity of a raw pointer after unlocking | | `client_mutex_` | Client-record registry, host information, and fields such as `ok_client_` | Continued validity of a registration after release; an atomic LOCAL_DISK admission-plus-metadata update | | `ClientLivenessRecord` serving/retaining guards | Ordering an operation against client liveness transitions | Explicit disk unmount; migration of one segment's allocator | | Segment-manager internal locks | Segment containers, state, and allocator registrations | A remount that spans multiple accesses; synchronized replacement of metadata buffers | | `SegmentAllocatorRegistration` | Allocation and buffer availability, allocator binding, and liveness references | One consistent commit across the metadata set and allocator import | | `LocalSsdManager` registry, client-lifecycle, stats, and mailbox locks | Registry operations and individual manager calls | Metadata writes that happen after a manager API returns | | Task-manager internal locks | Task queues and task state | Object and replica lifetime | | 4,096 `object_operation_locks_` entries | Same-object ordering for PutStart/UpsertStart | Other object entry points, cleanup, or restore as a whole | | Quota, group, discarded-replica, and related synchronization | Each local data structure and accounting operation | Consistent multi-component recovery, rollback, and publication | Relevant definitions are linked from [metadata and lock ordering][source-header], [client liveness][source-liveness], [allocator registration][source-registration], and [LocalSsdManager][source-ssd-header]. Reviewers should correct this inventory if any of these mechanisms has a broader or narrower contract than stated here. ### 3.2 Shared locking does not serialize ordinary requests Put, Get, Remove, eviction, and many other paths take the shared lock, but they still run concurrently with one another. Their ordinary data races are mainly handled by shard locks and other local mechanisms. The global lock's main extra role is to make these shared accesses mutually exclusive with four exclusive workflows. It does not give `RemoveAll()`, regex scans, or cross-shard queries a globally consistent snapshot of all ordinary requests. This distinction matters: 1. A function having a shard accessor does not prove that its outer shared guard is removable; an exclusive workflow may access the same object without taking that shard lock in the same interval. 2. A function lacking the outer guard is not automatically buggy; it may be fully protected by local locks or may only run before the state is published. ### 3.3 Four exclusive entry points | Entry point | Key operations | Preliminary assessment | | --- | --- | --- | | `ReMountSegment()`, L841 | Collect replicas across shards, import allocators, replace buffers, and clear endpoint unreadiness | Current implementation relies on global exclusion; not a direct deletion candidate | | `RestoreFromStandbyState()`, L3748 | Install metadata in chunks, restore auxiliary state and quota/weight data, perform repair and rollback | Needs complete recovery isolation; may not need a runtime global RW lock | | `GracefulUnmountSegment()`, L2930 | Check ownership, close allocation admission, update segment state, and schedule delayed unmount | Strong candidate for an independent review of whether exclusive global locking is needed | | `UnmountLocalDiskSegment()`, L8855 | Remove a LOCAL_DISK registry entry, release the lock, then sweep replicas owned by that client | Admission-to-write ordering must remain; the scope may be reduced to a client or registration | Source entry points: [remount][source-remount], [restore][source-restore], [graceful unmount][source-graceful], and [LOCAL_DISK unmount][source-local-disk]. ## 4. Risks exposed by direct lock removal ### 4.1 Remount retains raw pointers after releasing shard locks This is the clearest current memory-safety constraint. `ReMountSegment()` walks shards and collects `Replica*` and `ObjectMetadata*`. It then releases the shard locks, imports allocator state, and later uses those pointers to call `replace_memory_buffer()` and grant leases. The current exclusive global lock blocks other protocol-compliant readers and writers during that interval; see [the remount pointer lifetime][source-remount-pointers]. If the shared guard is removed from Remove, eviction, or durable finalize without changing remount, one legal interleaving is: ```text T1: remount finds Replica* p in shard S and releases S T2: Remove, eviction, or finalize acquires S and erases or changes the replica set T1: allocator import finishes and uses p to replace its buffer -> p may be invalid: use-after-free or corrupted state ``` Removing the shared guard from Get is not automatically safe either. Remount later mutates the buffer without reacquiring the metadata shard lock; a reader holding a shard read lock can still race with that replacement. **Working conclusion for discussion:** every path that can read or mutate these objects must not be declared removable merely because it has an accessor. First either eliminate remount's unlocked raw-pointer interval or retain an equivalent exclusion protocol during migration. The global lock is not a formal proof that every metadata access in the repository is covered. Bypassing helpers and callbacks still need auditing. The remount observation should not be broadened into a claim that the current implementation is otherwise race-free. ### 4.2 Read paths also access mutable state outside the shard `TryGetReadableReplicaDescriptor()` reads `invalid_replica_endpoints_`; restore replaces this set and remount erases entries from it. The set has no independent mutex at present; see [the readability check][source-readability]. This affects more than `GetReplicaList*`. `ExistKeyImpl()`, `BatchExistKeyImpl()`, and `GetAllKeys()` reach the same check through `HasReadableReplica()`. Eviction, Copy/Move, and drain scans have similar visibility decisions. Removing their shared guards without changing ownership or synchronization would permit concurrent access to an `unordered_set`, which is a C++ data race. A small lock for the set would solve container safety but would not solve the replica-buffer publication problem above. ### 4.3 RPC gating is not internal-thread gating during restore The production supervisor restores a candidate Master before registering or exposing its RPC service; see [the serving gate][source-supervisor]. This is a useful structure for eliminating a runtime global lock. However, `MasterService` starts eviction, client monitoring, task cleanup, offboarding, replica cleanup, job dispatch, and dynamic-replication workers during construction; see [the worker startup path][source-workers]. Restore itself may install metadata in chunks, rebuild quota state, replace auxiliary state, and perform durable repair. Removing only the exclusive restore guard could allow: - a background scan to observe only part of the restored chunks; - quota scan, rebuild, and publication to interleave with object cleanup or writes; - cleanup to make decisions before endpoint or liveness state is complete; and - rollback to race with concurrent creation, modification, or deletion. The fact that a worker is usually idle is not a synchronization contract. Tests, embedded callers, and future entry points may invoke restore directly. The first design question should therefore be whether restore is supported only for an unpublished, not-yet-serving instance. If yes, startup ordering or a one-time activation barrier can replace per-request participation in a global RW lock. ### 4.4 LOCAL_DISK has a check-to-write admission window `NotifyOffloadSuccess()` and `AddReplicaForRetainedClient()` currently perform the following while holding the shared state lock: 1. Check that the client is still registered as LOCAL_DISK. 2. Acquire the metadata shard lock. 3. Add or update the LOCAL_DISK replica. `UnmountLocalDiskSegment()` removes the registry entry under exclusive locking, releases the global lock, and then performs an owner-targeted sweep. The exclusive removal waits for prior shared admissions to finish; subsequent admission checks fail. The sweep does not need to hold the global exclusive lock for its entire duration if that admission ordering is preserved. Without an equivalent protocol, this interleaving is possible: ```text T1: Notify checks that the client is mounted, then waits for shard S T2: Unmount removes the client from the registry T2: the sweep visits S before T1 has written the replica T1: T1 obtains S and writes the replica -> an old-owner replica remains visible after unmount completes ``` `LocalSsdManager` already has a per-record lifecycle lock, and deregistration waits for manager operations that have acquired the record. But `HasMountedLocalDiskSegment()` calls `GetUsage()`, and that internal guard ends when `GetUsage()` returns; it does not cover a later metadata write. See [the manager implementation][source-ssd-impl] and [replica admission][source-add-replica]. `ClientLivenessRecord` guards protect client liveness. Explicit disk unmount does not currently acquire the same guard, and disk unmount is not equivalent to taking the entire client offline. Those lifetimes should not be conflated without an explicit contract. ### 4.5 Same-UUID remount needs an explicit contract The owner-targeted sweep avoids deleting objects registered by a **different client** while the sweep is in progress; an existing [interleaving test][source-ssd-test] covers that shape. The source still permits a different question: ```text Unmount(A): deregister A and release the exclusive lock Mount(A): register the same UUID again Notify(A): admit a LOCAL_DISK replica for the new registration Unmount(A): the old sweep deletes replicas by owner == A ``` The existing test uses different UUIDs for the leaving and late clients, so it does not establish same-UUID safety. Normal FileStorage drain uses its own latch and lock to constrain heartbeat remounts; see [the client drain path][source-file-drain]. This should therefore be described as a Master API contract and validation question, not as a reproduced production failure. Two possible contracts are: - prohibit a new registration for that UUID until the old sweep completes; or - allow overlap, but give registry entries, replicas, and cleanup work distinct registration identities or generations. ### 4.6 Does graceful unmount really need global exclusivity? We should not assume that removing this exclusive guard requires adding another global or segment lifecycle mutex. The current function already holds `ScopedSegmentAccess` across ownership validation, `PrepareGracefulUnmountSegment()`, and scheduler `Schedule()`. The segment path removes the allocator from the allocatable set and calls `SetAllocatable(false)`. `SegmentAllocatorRegistration::Allocate()` checks availability and liveness before and after allocation, and the allocator reference is accessed through atomic shared-pointer operations. See [segment state transition][source-segment] and [allocator registration][source-registration]. That makes this a useful independent review item: can “close admission for new allocations” be the linearization point, allowing already-admitted requests to finish under the existing segment and registration state, without blocking unrelated metadata? The following cases still need proof: - a request has copied an allocator reference and tries to commit after the transition; - a request passed the check but has not returned or committed; - remount, immediate unmount, expired-client offboarding, and timer callbacks overlap; and - an old deadline acts on a newly reused segment identity. The scheduler already releases its internal mutex before invoking the callback; see [DeadlineScheduler][source-scheduler]. A redesign must preserve that property and avoid a new scheduler/segment/client lock cycle. The historical ABBA fix is a design constraint, not proof that the current code always deadlocks. ### 4.7 Scans, accounting, and OpLog ordering cannot be delegated to one lock Cross-shard scans already permit ordinary requests to interleave. A finer-grained design should preserve or add “reacquire the shard, then revalidate object, replica ID, version, and state” checks instead of replacing every scan with a new global lock. The following points need explicit review: - Are the retaining-client sets captured by `ClearInvalidHandles()`, Put/Upsert, and BatchRemove stale? Mount takes the same shared lock today, so the old global lock does not make a one-time UUID snapshot complete for clients mounted later. A replica-bound liveness record may be more accurate than one copied UUID set, but compatibility with restored state must be checked. - Do quota, SSD used-bytes, and capacity metrics charge the correct object or registration? Atomic increments and decrements make individual counters safe, but do not prevent an old-generation decrement from being applied to a new generation found by UUID. - Must durable finalize retain identity checks against object deletion and recreation? The old shared lock never serialized two ordinary metadata operations, so existing ABA behavior should not automatically be attributed to lock removal. - If online restore is supported, must old callbacks be drained, cancelled, or tagged with a service/state generation? Startup-only restore reduces this problem substantially. - Are callbacks, workers, or durability completion ever awaited while holding a lock they need to make progress? Such waits can create new cycles. ### 4.8 Removing this lock may still leave global waits `ReMountSegment()` currently holds `client_mutex_` exclusively for the main recovery interval in addition to `master_state_mutex_`. `FindClientRecord()`, Put/Upsert, and other paths acquire the same client mutex. Removing only `master_state_mutex_` can therefore leave unrelated clients waiting behind remount. `RestoreFromStandbyState()` also holds both locks, although startup-only restore can remove its steady-state cost through lifecycle isolation. This does not mean the entire client registry must be redesigned in this RFC. It means two benefits must be measured separately: removing per-request participation in the global state mutex, and reducing lifecycle operations' blocking domain. To achieve the second benefit, remount may need to acquire stable liveness/resource identities, release the registry lock, and revalidate at commit; see [record lookup][source-client-record]. That would open remount/offboarding interleavings that are currently serialized, so `ok_client_`, `client_host_id_`, `standby_allocator_keepalive_`, and `standby_accounted_memory_bytes_` need explicit ownership rules. ## 5. Goals and boundaries ### 5.1 Goals 1. Ordinary object access should not unconditionally modify the same global shared mutex state. 2. Lifecycle operations should primarily affect the relevant client, segment, registration, or metadata shard. 3. Data-structure safety, object lifetime, and publication order should each have an explicit owner. 4. Existing contracts for readability, allocation safety, client liveness, leases, quota, and OpLog durability must remain intact. 5. Each removal step must explain which dependency it eliminates and provide targeted interleaving tests. ### 5.2 Explicitly out of scope for this RFC - Redesigning the OpLog snapshot format or persistence protocol. - Converting all metadata containers to lock-free or RCU structures. - Adding a new global-consistent-scan guarantee merely to remove the old lock. - Simultaneously rewriting every tenant, group, task, and allocator subsystem. - Claiming a specific performance improvement before measurements exist. ## 6. Alternatives and trade-offs | Option | Approach | Potential benefit | Cost and limitation | | --- | --- | --- | --- | | A: Keep a smaller global barrier | Remove independently justified task/mailbox sites; use client-level LOCAL_DISK synchronization; keep a global barrier around restore/remount for now | Lower immediate risk and easier staged review | Ordinary metadata hot paths may still touch a global lock; this is not the final goal | | B: Lifecycle isolation plus local publication | Complete restore before publication; remount through segment state/generation and shard-level rebinding; use a per-client or per-registration LOCAL_DISK protocol | Could remove the global lock completely and reduce conflict scope | Requires solving raw pointers, atomic publication, rollback, lock order, and compatibility contracts | | C: Versioned state or broad RCU | Build a complete new state and publish an immutable view atomically | May simplify read paths for a suitable data model | High change surface for metadata mutation, allocator reclamation, and multi-component updates; not recommended as the starting point | The suggested discussion direction is **B**, with **A** as a conservative intermediate. This is not a commitment to introduce every proposed generation or state type. The community should first decide which invariants require them. Renaming `master_state_mutex_` to `recovery_mutex_` while continuing to make ordinary requests acquire a shared guard would preserve the global hot-path coupling under a new name. ## 7. Preliminary design for discussion ### 7.1 Restore: turn concurrency into startup ordering where possible The first proposal is an explicit Master lifecycle: ```mermaid stateDiagram-v2 [*] --> Constructed Constructed --> Recovering: HA promotion Constructed --> Ready: non-HA initialization Recovering --> Ready: restore and durable repair succeed Recovering --> Failed: restore fails Ready --> Serving: activate workers and expose service Serving --> Stopping: leadership loss or shutdown Failed --> [*] Stopping --> [*] ``` The absence of a `Serving -> Recovering` edge is a proposed constraint, not an established API guarantee. Possible rules: - Construct components, but keep workers that mutate business metadata paused until a one-time activation. Alternatively, start threads that wait on an activation gate before doing useful work. - Finish metadata, quota/weight, endpoint state, and required durable repair before opening business workers and RPC/admin mutation entry points. - An OpLog writer or other infrastructure may need to run during recovery for durable repair. Separate recovery infrastructure from workers that process live business state instead of delaying every thread blindly. - Destroy a failed candidate rather than exposing it as Serving. Stop must wake workers waiting for activation. - Give tests and embedded mode an explicit initialization/activation API so they do not depend on constructors starting every worker immediately. This lets steady-state RPCs avoid a shared lock whose only purpose is protecting a phase that occurs before publication. If online restore is required, use a separate protocol: close admission, drain requests and callbacks that have entered, then restore and publish. A single atomic `restoring` flag is insufficient because a request can pass the check and remain in its critical interval. Waiting for drain while holding a callback-needed lock is also unsafe. Online restore should be a separately approved scope because it substantially expands the design. ### 7.2 Remount: eliminate address escape before choosing segment granularity Consider remount as three logical phases: prepare, rebind, and publish. Names and exact interfaces are intentionally left open for review. **Prepare:** identify the segment registration being recovered; prevent new allocation and premature reads; under shard locks collect stable identities such as tenant/key, replica ID, descriptor, and versions instead of retaining `Replica*` after unlock. **Rebind:** import allocations into the prepared allocator. Before changing metadata, reacquire the owning shard write lock, look up the object, and validate replica ID, old descriptor/registration, and version. If the object was deleted or changed, release the unused imported allocation or perform a bounded retry/failure; do not commit through an old pointer. **Publish:** publish segment allocatable/readable state only after buffers, allocator binding, accounting, and readiness satisfy the selected invariant. Keep the old allocator alive until dependent buffers have moved or been safely released. Questions that must be answered before implementation: 1. Between descriptor collection and commit, which object deletion, rewrite, or new replica operations are allowed? How are missing entries and unused imported allocations handled? 2. If an object spans multiple segments, may it be temporarily partially rebound? May reads return only replicas already valid? 3. For a request carrying multiple segments, must publication be batch-atomic, or can segments succeed independently with retryable failure? 4. On an error halfway through, is the contract full rollback, affected segments remaining invisible, or bounded retry? 5. Are segment name, endpoint, and UUID sufficient to identify a physical-resource generation, or is one common generation needed? Adding a per-segment mutex alone does not answer these questions. Remove and eviction may not know all segments involved before entering their shard, and may not acquire the same new lock. If that mutex is expected to preserve object lifetime, every relevant path must participate. A conservative transition can be evaluated: hold all relevant shard write locks in a fixed order across the rare remount pointer-use interval. If the shard set cannot be proven stable, temporarily holding all shards may remove the hot-path global mutex while still causing a broad pause. That is not segment-level isolation, and the order between multiple shards, client guards, and segment locks still requires proof. If a per-segment transition state or generation is selected, a candidate state machine is `Recovering -> Ready -> Retiring`. This RFC does not require those exact enum names. Reusing existing registration/segment state is preferable to maintaining two overlapping state machines. ### 7.3 Endpoint readability: put synchronization with the owner of the state For `invalid_replica_endpoints_`, compare: | Choice | Advantage | Review concern | | --- | --- | --- | | A small independent RW lock | Directly fixes container races | Read paths still touch a shared lock; lock ordering with shard locks must be proven | | Immutable endpoint set with atomic view publication | Complete views are easy for readers; suitable for read-mostly updates | Shared-pointer lifetime and update cost need measurement | | Readiness owned by a segment/registration object | State ownership follows the resource and can be generation-scoped | Must handle aliases, old generations, restore binding, and multi-segment publication | The current preference is to make readiness part of segment/registration state eventually, while allowing the first step to use one of the smaller mechanisms. Publishing readiness must follow correct buffer and allocator binding. Protecting the set itself is not a complete remount protocol. ### 7.4 LOCAL_DISK: organize admission, deregistration, and sweep by registration The invariant to preserve is: > A replica admission is either accepted into a still-valid registration and included in that registration's retirement work, or rejected. A sweep for an old registration must not delete replicas belonging to a new registration. Two routes are worth comparing. **D1: serialize disk lifecycle per client.** Mount, admission, and unmount share a stable per-client disk gate. Unmount closes admission, waits for already-admitted operations, sweeps, and only then allows a new mount for that UUID. It may be possible to reuse `ClientLivenessRecord` guards, since Mount and Notify already use related liveness checks. That would bind disk cleanup to heartbeat, memory operations, and liveness transitions, however. The impact on liveness latency, record cleanup, and long sweeps must be measured; reusing an existing guard is not automatically free. A dedicated per-client gate needs stable ownership. Removing a registry record must not allow a same-UUID remount to create a different gate while the old sweep still runs. A tombstone/retiring entry or independent gate owner may be required. **D2: explicit registration handle or generation.** Extend the existing `LocalSsdManager::ClientAccess` idea so a business operation receives a handle that spans admission, metadata write, and required accounting. Unmount marks that registration unavailable, then waits for in-flight handles. If a new generation may be created concurrently, the old sweep matches registration identity/generation rather than UUID alone. Implementation questions include: - Do not hold the registry container lock while waiting for a shard or sweeping the metadata table. - `ClientAccess` is currently private, and its lifecycle guard covers one manager method. Exposing a business handle requires a complete API rather than returning `GetUsage()` and assuming the guard survives. - Holding a handle and then calling a helper that reacquires the same lifecycle lock can deadlock or self-block. Accounting and mailbox operations may need handle-aware entry points. - If the disk gate is acquired before a shard, cleanup helpers called while holding a shard must not wait for that gate in the reverse order. - A Master-local generation distinguishes admitted local operations. Rejecting an old client RPC that arrives after a new mount may additionally require a wire/task token, which could affect protocol compatibility. - Durable finalize may occur after sweep returns. Used-byte decrements must target the old registration rather than looking up the UUID again and charging a new one. The first policy question is whether same-UUID parallel remount is needed at all. If it is forbidden, D1 is simpler. If it is allowed, D2 gives a more complete ownership model. Neither route should put unrelated clients' ordinary Get/Put operations behind a disk lifecycle gate. ### 7.5 Graceful unmount: start from existing segment synchronization Treat this as an independent review item. First determine whether the current segment mutex, registration availability, and scheduler already express the required semantics. A possible contract is: after allocation admission closes, new allocation fails or chooses another segment; operations admitted before the transition may finish under existing lifetime rules; physical unmount still follows the grace period and buffer validity. If the product requires all in-flight allocations to drain before the RPC returns, add an explicit in-flight protocol instead of relying on an incidental global lock. Even if this entry point's exclusive global guard is removed, it must not change the same segment midway through remount allocator import and publication. A local transition check or protocol may be sufficient; unrelated metadata should not continue to pay for a global barrier. ### 7.6 Business and background paths must obey the same invariant Ordinary metadata operations continue to use shard accessors. Object-local tasks, quota ledgers, and replica state changes remain under their owning shard or subsystem synchronization. Eviction, offboarding, promotion, dynamic replication, drain jobs, and durable finalize must not become loopholes around the new protocol. Scans should retain stable identities and revalidate at commit; registration-bearing operations must also validate lifecycle identity. Avoid hiding a new global gate inside `MetadataAccessor` to “cover all callers automatically”. That would recouple all shards and may break nested lock ordering. ## 8. Guard-site classification: what might be removed first The table below covers all 69 direct guard sites in the audit baseline. The line number is the start of the guard declaration. The categories mean: - **D — independent deletion candidate:** the body appears to be protected by local synchronization and has no identified need to participate in metadata/remount global exclusion. It still requires behavior and concurrency validation. - **M — metadata/remount prerequisite:** resolve raw pointers, buffer publication, and readiness first; online restore also requires recovery isolation. - **L — LOCAL_DISK prerequisite:** preserve registration admission, deregistration, or accounting semantics. - **C — lifecycle entry point:** independently prove state transitions and adjacent interleavings. ### 8.1 Four high-priority independent candidates | Location | Existing local protection | Why global shared locking may be removable, and boundary | | --- | --- | --- | | `PollRemoveAll`, L8933 | LocalSsdManager registry/client access and mailbox mutex | Consumes a mailbox flag only and does not access metadata; preserve unmount return semantics | | `PromotionObjectHeartbeat`, L10394 | Client serving guard, LocalSsdManager, and mailbox | Takes tasks only; later task APIs still validate object, replica, and state | | `FetchTasks`, L13317 | Client serving guard and task-manager write access | Pops tasks without changing replicas; should not freeze all metadata for this operation | | `MarkTaskToComplete`, L13337 | Client retaining guard and task-manager write access | Updates task-manager state and does not execute copy/move completion itself | These are D candidates, not approved deletions. If online restore later replaces these components, their service-admission contract must be revisited. ### 8.2 Other shared sites: remove by dependency | Location and shared-lock line | Category | Prerequisite or review focus | | --- | --- | --- | | `MountSegment`: L712 | C | Existing registry/client locks; interleaving with remount and graceful unmount for the same segment | | Four durable-finalize paths: L1739, 1832, 1854, 1893 | M | May delete or move replicas and invalidate remount-retained pointers; check late callbacks and identity | | `ClearInvalidHandles`: L2501 | M | Same pointer-lifetime issue; separately verify retaining-client snapshot freshness | | `ProcessClientOffboardingJob`: L2662 | M, L, C | OFFLINE must block new guards; coordinate segment retirement, explicit disk unmount, and callbacks | | `TaskCleanupThreadFunc`: L2851 | M | Also cleans soft pins and dynamic-replication state, so it is not task-manager-only | | `UnmountSegment`, `UnmountNoFSegment`: L2869, 2975 | M, C | Prepare/cleanup/commit, buffer invalidation, and remount ordering | | `ExistKeyImpl`, `BatchExistKeyImpl`, `GetAllKeys`: L3027, 3084, 3127 | M | Reach readability checks involving endpoint state and remount buffers | | `BatchReplicaClear`: L4382 | M | Modifies/deletes replicas; shard locking alone is not enough while remount retains pointers | | `GetReplicaListByRegex`: L4676 | M | Readability and leases; the scan is not a global snapshot today | | `GetReplicaList`, `GetReplicaListForAdmin`: L4709, 4812 | M | Readiness access; ordinary Get may also trigger promotion/dynamic replication | | `BatchGetReplicaList` and admin version: L4870, 5026 | M | Revalidate each shard read and later candidate submission | | `PutStart`, `UpsertStart`: L5475, 6163 | M, C | Allocation admission, metadata commit, stale cleanup; object stripes do not cover all paths | | `PutEnd`, `PutRevoke`: L5588, 5892 | M | Replica update and reclamation | | `AddReplicaForRetainedClient`: L5787 | M, L | Preserve the check-to-metadata-write interval for disk registration | | `CopyStart/End/Revoke`: L6833, 7045, 7226 | M, C | Source readiness, target allocation, object task state, and refcounts | | `MoveStart/End/Revoke`: L7305, 7451, 7645 | M, C | Same as Copy; verify source/target lock order | | `Remove`, `RemoveByRegex`: L7704, 7780 | M | May invalidate remount-retained pointers | | The two `RemoveAll` paths: L7878, 8023 | M, L | Per-shard cleanup, disk notification, and new-registration contract | | `BatchRemove`: L8164 | M | Retaining-client snapshot and per-shard deletion | | Bucket/shard DFS eviction: L8453, 8636 | M | Preserve allocator prepare/resolve and shard revalidation; prove multi-shard order | | `MountLocalDiskSegment`: L8802 | L | Internal registry insertion is protected; same-UUID old-sweep semantics remain to be designed | | `OffloadObjectHeartbeat`: L8896 | M, L | Disabling offload also changes object task/refcount state | | `ReportSsdCapacity`: L8949 | L | Relate internal capacity, external metric deltas, deregistration, and generations | | `NotifyOffloadSuccess` NACK: L9004 | M | Cleans task/refcount state; not adding a replica does not remove remount dependencies | | `NotifyOffloadSuccess` success branch: L9029 | M, L | Preserve admission-to-write ordering; audit used-byte updates outside the interval | | `RunPromotionCandidateRetry`: L9493, 9569 | M | Two-phase scan/commit; use stable identities and revalidate | | `DynamicReplicationAdmissionThreadFunc`, `SubmitReplicaActionProposal`: L9787, 10035 | M | Readiness, object state, and task admission | | `PromotionAllocStart`, `NotifyPromotionSuccess/Failure`: L10417, 10552, 10680 | M, C | Target allocation, source/target replica lifetime, and task accounting | | Cleanup interval in `EvictionThreadFunc`: L10847 | M | Walks shards and handles expired processing replicas | | Tenant eviction, `BatchEvict`, `NoFBatchEvict`: L11242, 11888, 12427 | M | Pointer/descriptor lifetime, readiness, group semantics, and parallel subscans | | `ClientMonitorFunc`, `TryUnmountNoFSegmentByHeartbeat`: L12651, 12762 | C, M | Client/NoF retirement and metadata cleanup | | `CreateCopyTask`: L13075, 13134; `CreateMoveTask`: L13194, 13263 | M | Rediscover, reacquire guard/shard, and revalidate; preserve two-phase ordering | | `ScheduleDrainJobTasks`, `MaybeCompleteDrainJob`: L13630, 13743 | M | Cross-shard results are candidates only; revalidate at task admission/completion | The table contains 61 shared sites here plus the four D sites above, for 65 shared sites. The four exclusive sites are listed in Section 3.3; they are protocol entry points rather than general deletion candidates. The previous broad claim “this only touches metadata, so its shared guard can be deleted” is not sufficient. In particular, existence queries, `GetAllKeys`, durable finalize, `BatchReplicaClear`, and the entire `TaskCleanupThreadFunc` need prerequisites and must not be placed on an unconditional deletion list. ## 9. Lock ordering and publication constraints The current header documents ordering among the client lock, tenant-policy mutex, master-state lock, metadata shard, quota recompute, segment/quota table, and soft-pin index. A new design also involves liveness transitions, LOCAL_DISK lifecycle gates, mailbox state, and scheduler callbacks. Renaming one line in the old table is not enough. At minimum, an implementation review should require: 1. A fixed order for acquiring multiple shards, including group and DFS operations. 2. No path that holds a shard while waiting for a transition guard that may turn around and wait for that shard. Use discover, release, reacquire in the prescribed order, and revalidate where necessary. 3. No LOCAL_DISK registry-container lock held while waiting for a shard or sweeping the metadata table. 4. No Master callback invoked while the scheduler mutex is held. 5. No callback, worker, or OpLog completion awaited while holding a lock required for that completion. 6. Atomic readiness/generation publication does not replace synchronization for ordinary containers; readers still need valid ownership and a matching state view. 7. Cleanup, timers, accounting, and callbacks for reused UUIDs or names must identify the intended resource generation. These are review constraints, not a proven deadlock-free lock graph. A complete dependency graph should be added after the community selects a design and its call paths. ## 10. Validation plan: correctness before performance claims ### 10.1 Targeted interleavings Use barriers and test hooks to force specific interleavings instead of relying only on random stress: | Scenario | Expected property to verify | | --- | --- | | Remove, Upsert, eviction, or durable finalize for the same key after remount collection | No invalid pointer; revalidation leads to correct commit, skip, or failure; no leak | | Get/Exist/BatchGet and Copy/Move during remount rebinding | No descriptor becomes visible before it is ready; no buffer race | | Mid-flight failure in a multi-segment remount | Allocator, buffer, readiness, quota, and metrics obey the selected rollback/failure-isolation contract | | Graceful unmount after an allocator snapshot has been obtained | Allocation and commit obey the agreed admission linearization | | Notify passes mount check but has not acquired a shard when Unmount sweeps | No new visible replica lands under the old registration | | Different UUID mounts during deregistration and sweep | New owner's objects remain; current test continues to pass | | Same UUID mount and late RPC/callback during deregistration and sweep | Contract rejects, waits, or distinguishes generations; old sweep does not delete new data | | Unmount, capacity reports, used-byte updates, and finalize overlap | Accounting does not cross generations; no negative or duplicate release | | Restore stalls during chunk installation or accounting publication | RPC/admin mutation and workers do not observe half-restored state | | Restore failure, leadership loss, Stop, and callbacks overlap | Failed instance is not exposed; no drain/join deadlock | | TTL offboarding, explicit disk unmount, and remount overlap | New work cannot bypass OFFLINE; record cleanup and resource generation agree | | RemoveAll, group eviction, drain, and ordinary writes overlap | Existing API behavior remains; per-shard revalidation is not lost | TSAN can supplement race detection, and ASAN plus targeted stress can expose pointer-lifetime bugs. Neither replaces assertions for stale registrations, duplicate allocations, or accounting order. Tests should cover HA and non-HA, different memory allocators, and the affected NoF/CXL branches. ### 10.2 Performance hypotheses and measurements The expected benefits are lower cache-line contention from shared lock state and less tail latency when a lifecycle operation runs. Both are hypotheses. Contention may move to client, shard, allocation, or quota locks. Compare the current baseline, an intermediate design, and the final design on: - Get/Exist/Put/Remove throughput and p50/p99/p999 latency across many shards; - unrelated-client latency and throughput during one client's disk unmount; - related and unrelated shard pauses during a large object remount; - wait time when long eviction or RemoveAll scans overlap lifecycle operations; - conflicts on new gates, generation retries, temporary imported-resource usage, and reclamation; and - hot-key, same-client, and cross-NUMA load shapes. The repository already has a [batch-eviction benchmark][source-benchmark] that observes global exclusive-lock waits through a test peer. After lock removal, translate such implementation-specific metrics into user-visible latency and isolation metrics rather than deleting regression coverage simply because the old mutex no longer exists. ## 11. Suggested discussion and delivery boundaries The following decomposition is offered for community selection, not as an approved implementation plan: 1. **Confirm contracts and measure the baseline.** Decide restore lifetime, remount atomicity, and same-UUID mount semantics; measure the actual global-lock cost. 2. **Review independently removable sites.** Start with the four D candidates in Section 8.1 and analyze graceful unmount separately. Each change should explain why it no longer needs to block the four exclusive workflows. 3. **Isolate LOCAL_DISK.** Choose D1 or D2; limit admission/deregistration ordering to the relevant client or registration; add sweep, metric, and finalize boundary tests. 4. **Isolate recovery.** Define initialization/activation for an unpublished instance and identify which writers or callbacks may run during recovery. 5. **Refactor remount and readiness.** Remove the unlocked raw-pointer dependency and define allocator import, buffer rebinding, publication, and failure recovery. 6. **Move ordinary metadata paths out of the barrier.** Remove shared guards by category, review helper contracts, and verify that background and synchronous paths obey the same invariants. 7. **Remove the last member and test coupling.** Update lock-order documentation, test peers, HA/tenant-quota/SSD tests, and benchmarks that directly reference the lock. Steps 3 and 4 can be designed independently. Step 6 depends on Steps 4 and 5; L-category paths also depend on Step 3. Shared guards should not be removed in bulk before those prerequisites are resolved. Every surviving exclusive workflow must remain correct during the mixed old/new transition. Each implementation PR should include its own correctness argument and validation results. Avoid one large PR that simultaneously changes startup APIs, registration identity, remount, wire protocol, and all guard sites. If the community decides to retain a transitional barrier only for necessary cases, document its remaining participants and its exit criteria. ## 12. Questions for reviewers and maintainers The central request of this RFC is a critical review. Please comment with concrete counterexamples, source references, or simpler alternatives, especially where the proposed design is over-engineered or where the current analysis is too optimistic. 1. **Restore boundary:** Is there a production or embedded use case that must restore an already-serving Master? If not, is a startup-only API acceptable? 2. **Remount atomicity:** Must all segments in one batch become visible together? What read result and retry behavior are allowed for an object whose replicas are only partially rebound? 3. **Remount transition:** Is a rare pause that locks relevant or all shards acceptable as an intermediate step to remove the global RW lock from ordinary paths, or is true segment-level isolation required immediately? 4. **Graceful-unmount linearization:** After new allocation admission closes, may already-admitted operations finish? Are current registration checks enough, or is explicit in-flight drain required? 5. **LOCAL_DISK identity:** May Mount and Unmount for one UUID overlap? Should the first implementation serialize per client, or introduce registration generations? Must late RPCs from an old generation be rejected? 6. **Readiness ownership:** Should endpoint readiness remain an independent set, use an immutable view, or move into segment registration? Which aliases and restore-compatibility constraints are mandatory? 7. **Persistence and asynchronous completion:** Which callbacks, workers, or tests truly depend on global exclusion? Which existing identity/version fields can be reused, and which semantics are not yet defined? 8. **Lock-order review:** Which call path or helper has been omitted from the proposed ordering? Are there shard-to-manager or manager-to-shard paths that make D1/D2 unsafe? 9. **Performance priority:** Is the main target steady-state throughput, unmount tail latency, or HA promotion/remount pause? What production telemetry or benchmark should decide between A and B? 10. **Analysis errors:** Which statement in this RFC is factually wrong, too strong, or based on a test that does not cover the claimed interleaving? Please challenge the four D candidates in particular. The desired outcome of this discussion is not immediate approval. It is a shared list of invariants, rejected assumptions, and an implementation boundary that developers can defend before coding starts. --- This draft is based on the specified source commit, repository history, and existing tests. It does not modify runtime code and does not claim that a lock-removal experiment or performance test has been run. The interleavings are code-derived hypotheses and are labelled as validation targets. This RFC is not a proof that the current implementation or any proposal is free of all concurrency bugs. This RFC draft was prepared with AI assistance. Any feedback, error corrections, or architectural suggestions are warmly welcome, and I will incorporate them with an open mind so we can work together toward clean, efficient, and robust code. ### Before submitting a new issue... - [ ] Make sure you already searched for relevant issues and read the [documentation](https://kvcache-ai.github.io/Mooncake/)
Author
Owner

@github-actions[bot] commented on GitHub (Oct 15, 2026):

Thanks for opening this issue, @Icedcoco!

Field Value
Issue #4700
GitHub user ID 102317026
Reporter @Icedcoco

A maintainer will triage this when possible. To help us respond faster, please include:

  • Mooncake version or commit SHA
  • Environment (OS, CUDA/driver, RDMA stack if relevant)
  • Steps to reproduce and expected vs. actual behavior

Useful links: Documentation · Contributing guide

This message was posted automatically by the issue bot.

<!-- gh-comment-id:6196679384 --> @github-actions[bot] commented on GitHub (Oct 15, 2026): Thanks for opening this issue, @Icedcoco! | Field | Value | |-------|-------| | **Issue** | #4700 | | **GitHub user ID** | `102317026` | | **Reporter** | @Icedcoco | A maintainer will triage this when possible. To help us respond faster, please include: - Mooncake version or commit SHA - Environment (OS, CUDA/driver, RDMA stack if relevant) - Steps to reproduce and expected vs. actual behavior Useful links: [Documentation](https://kvcache-ai.github.io/Mooncake/) · [Contributing guide](https://github.com/kvcache-ai/Mooncake/blob/main/CONTRIBUTING.md) > This message was posted automatically by the issue bot.
Author
Owner

@kunkunblueberry commented on GitHub (Oct 17, 2026):

Thanks for this RFC — the per-site D/M/L/C classification and the explicit interleaving sketches make this far easier to review than a plain "remove the global lock" proposal would be. Especially useful: stating the no-cycles-out-of-Serving constraint as a proposal rather than an assumed invariant, and isolating the 4 D-class candidates from the harder remount/restore work.

I would like to participate on parts of this rather than take over the whole decomposition. Concretely I can help with:

  • Validating (or refuting) the interleaving assumptions with targeted fault-injection tests
  • Implementing specific slices — I plan to prototype the restore lifecycle + admission slice (Section 5.1) and share it as a draft PR for discussion before touching anything broader
  • Upstream validation on a multi-node RDMA cluster with real HA failover cycles

One observation from my initial read: Question 5 (same-UUID remount semantics) seems to block the most downstream design choices, including the remount publish phase — if a later remount is linearized on the bucket lock over group_id, the publish phase can stay much simpler than the epoch-merge version. Happy to help write that decision down once it is settled.

<!-- gh-comment-id:6231844777 --> @kunkunblueberry commented on GitHub (Oct 17, 2026): Thanks for this RFC — the per-site D/M/L/C classification and the explicit interleaving sketches make this far easier to review than a plain "remove the global lock" proposal would be. Especially useful: stating the no-cycles-out-of-Serving constraint as a proposal rather than an assumed invariant, and isolating the 4 D-class candidates from the harder remount/restore work. I would like to participate on parts of this rather than take over the whole decomposition. Concretely I can help with: - Validating (or refuting) the interleaving assumptions with targeted fault-injection tests - Implementing specific slices — I plan to prototype the restore lifecycle + admission slice (Section 5.1) and share it as a draft PR for discussion before touching anything broader - Upstream validation on a multi-node RDMA cluster with real HA failover cycles One observation from my initial read: Question 5 (same-UUID remount semantics) seems to block the most downstream design choices, including the remount publish phase — if a later remount is linearized on the bucket lock over group_id, the publish phase can stay much simpler than the epoch-merge version. Happy to help write that decision down once it is settled.
Author
Owner

@he-yufeng commented on GitHub (Oct 18, 2026):

Baseline checks out against main: #4406 did land (10-13) and the renamed master_state_mutex_ is now confined to a single file, master_service.cpp, with 71 acquisition sites. Two measurements from that file that should sharpen the decomposition:

  1. The lock is overwhelmingly read-side: 55 of the 71 sites take shared_lock (including the snap_lock/snapshot_lock naming leftovers from the old path), and only 4 take unique_lock. So the contention story is writer-waits-on-readers, and the decomposition risk lives in which of those 55 read sites actually depend on the same invariant as one of the 4 writers. A guard-site map (each acquisition, the fields it reads, and the writer it pairs with) would make the RFC's "unrelated lifecycle or metadata interactions" claim auditable instead of asserted; without it, splitting the lock is unfalsifiable either way.

  2. Since the surface is one file, a cheaper first step might be mechanical: annotate each site with the invariant it participates in (a short comment convention is enough), then let the protocol decomposition fall out of the clusters. That produces the migration plan as a byproduct, and it is reviewable in one PR without any behavior change.

Happy to review the guard-site map when it exists.

<!-- gh-comment-id:6246258965 --> @he-yufeng commented on GitHub (Oct 18, 2026): Baseline checks out against main: #4406 did land (10-13) and the renamed `master_state_mutex_` is now confined to a single file, master_service.cpp, with 71 acquisition sites. Two measurements from that file that should sharpen the decomposition: 1. The lock is overwhelmingly read-side: 55 of the 71 sites take `shared_lock` (including the `snap_lock`/`snapshot_lock` naming leftovers from the old path), and only 4 take `unique_lock`. So the contention story is writer-waits-on-readers, and the decomposition risk lives in which of those 55 read sites actually depend on the same invariant as one of the 4 writers. A guard-site map (each acquisition, the fields it reads, and the writer it pairs with) would make the RFC's "unrelated lifecycle or metadata interactions" claim auditable instead of asserted; without it, splitting the lock is unfalsifiable either way. 2. Since the surface is one file, a cheaper first step might be mechanical: annotate each site with the invariant it participates in (a short comment convention is enough), then let the protocol decomposition fall out of the clusters. That produces the migration plan as a byproduct, and it is reviewable in one PR without any behavior change. Happy to review the guard-site map when it exists.
Author
Owner

@kunkunblueberry commented on GitHub (Oct 18, 2026):

Two things: a correction to the baseline count, and the guard-site map you asked for.

Count

The file has 59 acquisition sites — 55 shared_lock, 4 unique_lock. 71 is the number of lines that mention the symbol: 59 sites + 10 wrapped declarations (the master_state_mutex_); continuation line) + 2 comments. Reproduce with:

grep -nE "std::(shared|unique)_lock<std::shared_mutex>" mooncake-store/src/master_service.cpp \
  | grep -c master_state_mutex_     # 59   (55 shared / 4 unique)

So the writer:reader ratio is the 4:55 you measured, but the denominator is 59, not 71.

The four exclusive sites are all segment-registry lifecycle

line function what it mutates
819 ReMountSegment segment_manager_, standby accounting, invalid replica endpoints
2900 GracefulUnmountSegment segment_manager_ (drain scheduling)
3775 RestoreFromStandbyState full metadata rebuild (segments, allocator, objects)
8945 UnmountLocalDiskSegment local_ssd_manager_ registration

Guard-site map

Static analysis of each acquisition plus the member fields its enclosing function touches. xs = iterates all kNumShards under the lock (needs a consistent cross-shard view, not exclusion).

line function lock primary invariant also touches
458 dynamic_replication_ callback shared object metadata (read) promotion
819 ReMountSegment unique segment registry (W) client registry, standby accounting
1704 FinalizeRemovedReplicasAfterDurable shared object metadata (W: finalize removal) oplog callback
1798 FinalizeMetadataEraseAfterDurable shared object metadata (W: erase) oplog callback
1820 FinalizeExpiredProcessingReplicasAfterDurable shared object metadata (W) discarded_replicas_
1859 FinalizeExpiredReplicationTaskAfterDurable shared object metadata (W) dynamic replication
2471 ClearInvalidHandles shared object metadata (W: stale handles) client registry
2632 ProcessClientOffboardingJob shared client registry segment registry, metadata, local SSD
2821 TaskCleanupThreadFunc shared task registry (W) —
2839 UnmountSegment shared segment registry (read) client registry
2900 GracefulUnmountSegment unique segment registry (W) drain scheduler
2945 UnmountNoFSegment shared NoF registry (W) client registry
2997 ExistKeyImpl shared object metadata (read) —
3101 BatchExistKeyImpl shared object metadata (read) —
3144 GetAllKeys xs shared object metadata (read) —
3775 RestoreFromStandbyState unique full rebuild (W) segments, allocator, standby
4455 BatchReplicaClear shared object metadata (W: lease clear) oplog
4740 GetReplicaListByRegex xs shared object metadata (read) —
4773 GetReplicaList shared object metadata (read) promotion
4876 GetReplicaListForAdmin shared object metadata (read) —
4934 BatchGetReplicaList shared object metadata (read) promotion
5090 BatchGetReplicaListForAdmin shared object metadata (read) —
5652 PutEnd shared object metadata (W) oplog, offload
5851 AddReplicaForRetainedClient shared object metadata (W) quota, oplog
5956 PutRevoke shared object metadata (W) oplog
6933 CopyStart shared object metadata (read) + segment alloc dynamic replication
7128 CopyEnd shared object metadata (W) oplog
7312 CopyRevoke shared object metadata (W) dynamic replication
7392 MoveStart shared object metadata (read) + segment alloc —
7539 MoveEnd shared object metadata (W) oplog
7733 MoveRevoke shared object metadata (W) —
7792 Remove shared object metadata (W) oplog
7868 RemoveByRegex xs shared object metadata (W) oplog
7966 RemoveAll shared object metadata (W) oplog, local SSD
8112 RemoveAll (overload) shared object metadata (W) oplog, local SSD
8254 BatchRemove shared object metadata (W) oplog, client registry
8945 UnmountLocalDiskSegment unique local SSD registry (W) —
8986 OffloadObjectHeartbeat shared object metadata (read) local SSD
9023 PollRemoveAll shared object metadata (read) local SSD
9039 ReportSsdCapacity shared local SSD accounting —
9465 RunPromotionCandidateRetry shared promotion state metadata
9505 RunPromotionCandidateRetry shared promotion state metadata
9579 SubmitReplicaActionProposal shared object metadata (read) dynamic replication
9885 PromotionObjectHeartbeat shared promotion state local SSD
9908 PromotionAllocStart shared segment alloc object metadata
10043 NotifyPromotionSuccess shared object metadata (W) oplog
10171 NotifyPromotionFailure shared promotion state (W) local SSD
10736 EvictTenantMemoryForQuota shared object metadata (W: evict) quota, offload
11382 BatchEvict shared object metadata (W: evict) oplog, offload, quota
11921 NoFBatchEvict shared object metadata (W: evict) oplog, NoF
12257 TryUnmountNoFSegmentByHeartbeat shared NoF registry (W) client registry, metadata
12512 CreateCopyTask shared task registry (W) segment registry (read)
12571 CreateCopyTask (2nd) shared task registry (W) segment registry (read)
12631 CreateMoveTask shared task registry (W) segment registry (read)
12700 CreateMoveTask (2nd) shared task registry (W) segment registry (read)
12754 FetchTasks shared task registry (read) —
12774 MarkTaskToComplete shared task registry (W) —
13067 ScheduleDrainJobTasks xs shared task registry + drain object metadata (read)
13181 MaybeCompleteDrainJob xs shared task registry + drain segment registry

What the map says about the decomposition

  1. Every object-metadata mutation holds the global lock in shared mode. PutEnd, PutRevoke, CopyEnd, MoveEnd, Remove*, BatchEvict, NotifyPromotionSuccess all write metadata_shards_ while sharing master_state_mutex_; exclusivity there comes from MetadataAccessorRW / MetadataShardAccessorRW, i.e. lock-order level 4, not level 3. So for those 18 sites the global lock cannot be protecting writer-writer exclusion. What it does protect is (a) ordering against the four exclusive sites above, and (b) for the xs sites, a consistent view across shards.

  2. That splits the 55 shared sites into two different dependencies, and they need different replacements:

    • ordering against segment lifecycle — anything that resolves segment names or handles while a segment can be unmounted, re-mounted, or rebuilt (PutEnd, Copy*, Move*, eviction, offload, promotion, drain). These need the barrier to survive in some form; the natural target is "registration/unmount publishes a new registry generation, readers pin the generation they resolved against".
    • cross-shard snapshot — the cross-shard scans in the file: 5 sit directly under a lock (GetAllKeys, GetReplicaListByRegex, RemoveByRegex, ScheduleDrainJobTasks, MaybeCompleteDrainJob) and 4 more live in helpers invoked under the lock (TenantHasObjects, RebuildTenantQuotaUsageFromMetadata, ReRouteRestoredObjectsByKey, ClearCandidatesForReload). These need a snapshot, not exclusion; a shard-set epoch would let them go lock-free, and that is independent of the protocol split.
  3. Clusters that look removable first because they touch only their own subsystem: task registry (9 sites, task_manager_ only), promotion state (9 sites), local-SSD capacity (ReportSsdCapacity, PollRemoveAll, OffloadObjectHeartbeat). None of these resolve segment names, so they do not need the segment-lifecycle barrier.

  4. The comment above UnmountLocalDiskSegment (lines ~8938-8945) already states the exact hazard: a registration admitted against the old segment must not land in a shard the sweep has already passed. That is precisely the invariant to encode once, rather than rediscover per site — and it is the one place where the exclusive lock is load-bearing for correctness, not just convenience.

If the annotation convention is the preferred format, I can turn the table into one short tag per site (e.g. // [inv:segment-registry], // [inv:object-metadata], // [inv:cross-shard]) as a separate no-behavior-change PR, which also makes the cluster counts greppable.

<!-- gh-comment-id:6249694682 --> @kunkunblueberry commented on GitHub (Oct 18, 2026): Two things: a correction to the baseline count, and the guard-site map you asked for. ### Count The file has **59** acquisition sites — 55 `shared_lock`, 4 `unique_lock`. 71 is the number of lines that *mention* the symbol: 59 sites + 10 wrapped declarations (the `master_state_mutex_);` continuation line) + 2 comments. Reproduce with: ```bash grep -nE "std::(shared|unique)_lock<std::shared_mutex>" mooncake-store/src/master_service.cpp \ | grep -c master_state_mutex_ # 59 (55 shared / 4 unique) ``` So the writer:reader ratio is the 4:55 you measured, but the denominator is 59, not 71. ### The four exclusive sites are all segment-registry lifecycle | line | function | what it mutates | | --: | --- | --- | | 819 | `ReMountSegment` | `segment_manager_`, standby accounting, invalid replica endpoints | | 2900 | `GracefulUnmountSegment` | `segment_manager_` (drain scheduling) | | 3775 | `RestoreFromStandbyState` | full metadata rebuild (segments, allocator, objects) | | 8945 | `UnmountLocalDiskSegment` | `local_ssd_manager_` registration | ### Guard-site map Static analysis of each acquisition plus the member fields its enclosing function touches. `xs` = iterates all `kNumShards` under the lock (needs a consistent cross-shard view, not exclusion). | line | function | lock | primary invariant | also touches | | --: | --- | --- | --- | --- | | 458 | `dynamic_replication_` callback | shared | object metadata (read) | promotion | | 819 | `ReMountSegment` | **unique** | segment registry (W) | client registry, standby accounting | | 1704 | `FinalizeRemovedReplicasAfterDurable` | shared | object metadata (W: finalize removal) | oplog callback | | 1798 | `FinalizeMetadataEraseAfterDurable` | shared | object metadata (W: erase) | oplog callback | | 1820 | `FinalizeExpiredProcessingReplicasAfterDurable` | shared | object metadata (W) | `discarded_replicas_` | | 1859 | `FinalizeExpiredReplicationTaskAfterDurable` | shared | object metadata (W) | dynamic replication | | 2471 | `ClearInvalidHandles` | shared | object metadata (W: stale handles) | client registry | | 2632 | `ProcessClientOffboardingJob` | shared | client registry | segment registry, metadata, local SSD | | 2821 | `TaskCleanupThreadFunc` | shared | task registry (W) | — | | 2839 | `UnmountSegment` | shared | segment registry (read) | client registry | | 2900 | `GracefulUnmountSegment` | **unique** | segment registry (W) | drain scheduler | | 2945 | `UnmountNoFSegment` | shared | NoF registry (W) | client registry | | 2997 | `ExistKeyImpl` | shared | object metadata (read) | — | | 3101 | `BatchExistKeyImpl` | shared | object metadata (read) | — | | 3144 | `GetAllKeys` xs | shared | object metadata (read) | — | | 3775 | `RestoreFromStandbyState` | **unique** | full rebuild (W) | segments, allocator, standby | | 4455 | `BatchReplicaClear` | shared | object metadata (W: lease clear) | oplog | | 4740 | `GetReplicaListByRegex` xs | shared | object metadata (read) | — | | 4773 | `GetReplicaList` | shared | object metadata (read) | promotion | | 4876 | `GetReplicaListForAdmin` | shared | object metadata (read) | — | | 4934 | `BatchGetReplicaList` | shared | object metadata (read) | promotion | | 5090 | `BatchGetReplicaListForAdmin` | shared | object metadata (read) | — | | 5652 | `PutEnd` | shared | object metadata (W) | oplog, offload | | 5851 | `AddReplicaForRetainedClient` | shared | object metadata (W) | quota, oplog | | 5956 | `PutRevoke` | shared | object metadata (W) | oplog | | 6933 | `CopyStart` | shared | object metadata (read) + segment alloc | dynamic replication | | 7128 | `CopyEnd` | shared | object metadata (W) | oplog | | 7312 | `CopyRevoke` | shared | object metadata (W) | dynamic replication | | 7392 | `MoveStart` | shared | object metadata (read) + segment alloc | — | | 7539 | `MoveEnd` | shared | object metadata (W) | oplog | | 7733 | `MoveRevoke` | shared | object metadata (W) | — | | 7792 | `Remove` | shared | object metadata (W) | oplog | | 7868 | `RemoveByRegex` xs | shared | object metadata (W) | oplog | | 7966 | `RemoveAll` | shared | object metadata (W) | oplog, local SSD | | 8112 | `RemoveAll` (overload) | shared | object metadata (W) | oplog, local SSD | | 8254 | `BatchRemove` | shared | object metadata (W) | oplog, client registry | | 8945 | `UnmountLocalDiskSegment` | **unique** | local SSD registry (W) | — | | 8986 | `OffloadObjectHeartbeat` | shared | object metadata (read) | local SSD | | 9023 | `PollRemoveAll` | shared | object metadata (read) | local SSD | | 9039 | `ReportSsdCapacity` | shared | local SSD accounting | — | | 9465 | `RunPromotionCandidateRetry` | shared | promotion state | metadata | | 9505 | `RunPromotionCandidateRetry` | shared | promotion state | metadata | | 9579 | `SubmitReplicaActionProposal` | shared | object metadata (read) | dynamic replication | | 9885 | `PromotionObjectHeartbeat` | shared | promotion state | local SSD | | 9908 | `PromotionAllocStart` | shared | segment alloc | object metadata | | 10043 | `NotifyPromotionSuccess` | shared | object metadata (W) | oplog | | 10171 | `NotifyPromotionFailure` | shared | promotion state (W) | local SSD | | 10736 | `EvictTenantMemoryForQuota` | shared | object metadata (W: evict) | quota, offload | | 11382 | `BatchEvict` | shared | object metadata (W: evict) | oplog, offload, quota | | 11921 | `NoFBatchEvict` | shared | object metadata (W: evict) | oplog, NoF | | 12257 | `TryUnmountNoFSegmentByHeartbeat` | shared | NoF registry (W) | client registry, metadata | | 12512 | `CreateCopyTask` | shared | task registry (W) | segment registry (read) | | 12571 | `CreateCopyTask` (2nd) | shared | task registry (W) | segment registry (read) | | 12631 | `CreateMoveTask` | shared | task registry (W) | segment registry (read) | | 12700 | `CreateMoveTask` (2nd) | shared | task registry (W) | segment registry (read) | | 12754 | `FetchTasks` | shared | task registry (read) | — | | 12774 | `MarkTaskToComplete` | shared | task registry (W) | — | | 13067 | `ScheduleDrainJobTasks` xs | shared | task registry + drain | object metadata (read) | | 13181 | `MaybeCompleteDrainJob` xs | shared | task registry + drain | segment registry | ### What the map says about the decomposition 1. **Every object-metadata mutation holds the global lock in *shared* mode.** `PutEnd`, `PutRevoke`, `CopyEnd`, `MoveEnd`, `Remove*`, `BatchEvict`, `NotifyPromotionSuccess` all write `metadata_shards_` while sharing `master_state_mutex_`; exclusivity there comes from `MetadataAccessorRW` / `MetadataShardAccessorRW`, i.e. lock-order level 4, not level 3. So for those 18 sites the global lock cannot be protecting writer-writer exclusion. What it does protect is (a) ordering against the four exclusive sites above, and (b) for the `xs` sites, a consistent view across shards. 2. **That splits the 55 shared sites into two different dependencies, and they need different replacements:** - *ordering against segment lifecycle* — anything that resolves segment names or handles while a segment can be unmounted, re-mounted, or rebuilt (`PutEnd`, `Copy*`, `Move*`, eviction, offload, promotion, drain). These need the barrier to survive in some form; the natural target is "registration/unmount publishes a new registry generation, readers pin the generation they resolved against". - *cross-shard snapshot* — the cross-shard scans in the file: 5 sit directly under a lock (`GetAllKeys`, `GetReplicaListByRegex`, `RemoveByRegex`, `ScheduleDrainJobTasks`, `MaybeCompleteDrainJob`) and 4 more live in helpers invoked under the lock (`TenantHasObjects`, `RebuildTenantQuotaUsageFromMetadata`, `ReRouteRestoredObjectsByKey`, `ClearCandidatesForReload`). These need a snapshot, not exclusion; a shard-set epoch would let them go lock-free, and that is independent of the protocol split. 3. **Clusters that look removable first** because they touch only their own subsystem: task registry (9 sites, `task_manager_` only), promotion state (9 sites), local-SSD capacity (`ReportSsdCapacity`, `PollRemoveAll`, `OffloadObjectHeartbeat`). None of these resolve segment names, so they do not need the segment-lifecycle barrier. 4. The comment above `UnmountLocalDiskSegment` (lines ~8938-8945) already states the exact hazard: a registration admitted against the old segment must not land in a shard the sweep has already passed. That is precisely the invariant to encode once, rather than rediscover per site — and it is the one place where the exclusive lock is load-bearing for correctness, not just convenience. If the annotation convention is the preferred format, I can turn the table into one short tag per site (e.g. `// [inv:segment-registry]`, `// [inv:object-metadata]`, `// [inv:cross-shard]`) as a separate no-behavior-change PR, which also makes the cluster counts greppable.
Author
Owner

@he-yufeng commented on GitHub (Oct 18, 2026):

The map is exactly what the decomposition question needed, and the count correction checks out: 59 acquisition sites (55 shared / 4 unique), so the ratio I measured stands with the right denominator.

The load-bearing finding in your table is not the ratio but point 1: every object-metadata mutation holds the global lock in shared mode, so writer-writer exclusion for those sites has never come from master_state_mutex_ — it comes from the level-4 accessors. That means sharding or removing the global lock does not touch write correctness on the hot path at all; the only real dependencies are the two you isolated, ordering against the four segment-lifecycle sites and consistent views for the cross-shard scans. Two different replacements for two different dependencies is the right shape, and the registry-generation pin for segment lifecycle matches how the UnmountLocalDiskSegment comment already states the hazard.

On sequencing, the removable-first clusters (task registry, promotion state, local-SSD capacity) are the right first extractions precisely because they do not resolve segment names. One thing the annotation pass should double-check before each extraction: the "also touches" column. Sites like PutEnd (oplog, offload), BatchRemove (client registry), or the drain pair sit in a primary cluster but reach into a second subsystem, and any ordering those secondary touches get for free today has to be made explicit or consciously dropped. The map already records the touches, so this is a verification step, not new analysis.

On the annotation PR: yes, worth doing as a separate no-behavior-change PR, and I would tag two axes rather than one — the invariant domain ([inv:task-registry] etc.) and what the site actually needs from the global lock ([needs:lifecycle-order], [needs:cross-shard-view], [needs:none]). The first axis tells you which cluster a site belongs to; the second tells you whether extraction is safe, and they do not correlate perfectly (the promotion and task sites are [needs:none], but a metadata writer like CopyStart that resolves segment handles is [needs:lifecycle-order]). With both tags greppable, each extraction PR becomes a mechanical review of one tag set.

<!-- gh-comment-id:6252691060 --> @he-yufeng commented on GitHub (Oct 18, 2026): The map is exactly what the decomposition question needed, and the count correction checks out: 59 acquisition sites (55 shared / 4 unique), so the ratio I measured stands with the right denominator. The load-bearing finding in your table is not the ratio but point 1: every object-metadata mutation holds the global lock in *shared* mode, so writer-writer exclusion for those sites has never come from `master_state_mutex_` — it comes from the level-4 accessors. That means sharding or removing the global lock does not touch write correctness on the hot path at all; the only real dependencies are the two you isolated, ordering against the four segment-lifecycle sites and consistent views for the cross-shard scans. Two different replacements for two different dependencies is the right shape, and the registry-generation pin for segment lifecycle matches how the `UnmountLocalDiskSegment` comment already states the hazard. On sequencing, the removable-first clusters (task registry, promotion state, local-SSD capacity) are the right first extractions precisely because they do not resolve segment names. One thing the annotation pass should double-check before each extraction: the "also touches" column. Sites like `PutEnd` (oplog, offload), `BatchRemove` (client registry), or the drain pair sit in a primary cluster but reach into a second subsystem, and any ordering those secondary touches get for free today has to be made explicit or consciously dropped. The map already records the touches, so this is a verification step, not new analysis. On the annotation PR: yes, worth doing as a separate no-behavior-change PR, and I would tag two axes rather than one — the invariant domain (`[inv:task-registry]` etc.) and what the site actually needs from the global lock (`[needs:lifecycle-order]`, `[needs:cross-shard-view]`, `[needs:none]`). The first axis tells you which cluster a site belongs to; the second tells you whether extraction is safe, and they do not correlate perfectly (the promotion and task sites are `[needs:none]`, but a metadata writer like `CopyStart` that resolves segment handles is `[needs:lifecycle-order]`). With both tags greppable, each extraction PR becomes a mechanical review of one tag set.
Author
Owner

@Icedcoco commented on GitHub (Oct 20, 2026):

Thanks for offering to help with this. My current direction is to reduce the scope of master_state_mutex_ incrementally rather than assume that the global lock needs to remain around every path. Each path should use synchronization that matches the state and invariants it actually protects, while preserving the existing externally visible behavior.

Since you offered to prototype restore lifecycle and admission, please feel free to start with that as a focused PR. Please include the lock sites changed and targeted tests for concurrent restore admission and failure or cleanup paths.

We can review the same-UUID remount behavior and the group_id locking idea against the concrete implementation and tests, then decide whether the approach should be adjusted or extended. I’ll be happy to work through that review with you. @kunkunblueberry

<!-- gh-comment-id:6292008578 --> @Icedcoco commented on GitHub (Oct 20, 2026): Thanks for offering to help with this. My current direction is to reduce the scope of `master_state_mutex_` incrementally rather than assume that the global lock needs to remain around every path. Each path should use synchronization that matches the state and invariants it actually protects, while preserving the existing externally visible behavior. Since you offered to prototype restore lifecycle and admission, please feel free to start with that as a focused PR. Please include the lock sites changed and targeted tests for concurrent restore admission and failure or cleanup paths. We can review the same-UUID remount behavior and the `group_id` locking idea against the concrete implementation and tests, then decide whether the approach should be adjusted or extended. I’ll be happy to work through that review with you. @kunkunblueberry
Author
Owner

@kunkunblueberry commented on GitHub (Oct 20, 2026):

Two replies, one per thread.

@Icedcoco — the restore lifecycle prototype already exists as a PR: #4736. It is the focused slice you described, and it is rebased onto current main (95750b2c). On your three asks:

  • Lock sites changed: none, deliberately. Slice 1 adds the lifecycle state machine and worker admission and changes zero locking behaviour — master_state_mutex_ stays exactly where it is. I made that split on purpose so the state machine can be reviewed without arguing about lock removal at the same time. The lock-site work starts with the annotation PR below, then one extraction.
  • Concurrent restore admission: RestoreLifecycleOverlappingRestoreIsRefused. It holds the restore core's exclusive lock through the test peer, so Recovering is published while the core cannot progress; the overlapping call must then be refused at admission. This is a deterministic window, not a timing race (an earlier version of the test was timing-dependent and a reviewer correctly rejected it).
  • Failure / cleanup paths: RestoreLifecycleFailedRestoreReachesFailed, RestoreLifecycleRetryAfterFailedRestoreReachesReady (replacement-restore retry from Failed), RestoreLifecycleRestoreAfterServingIsRefused, and RestoreLifecycleWorkersPausedOnlyInRestoreWindows for the worker gate.

Locally on the rebased tree: lifecycle 9/9, master_service_test 71/71, master_service_ha_test 127/130 with the same three environment-only BatchEvict* failures documented in the PR.

@he-yufeng — the two-axis tagging is what I built, and you are right about [needs:none]; I had it wrong. Correction first, because it changes the annotation: I originally tagged the task and promotion sites [needs:lifecycle-order] on the premise that the restore clears the task table via TaskManagerSerializer::Reset(). I checked before writing this: TaskManagerSerializer has no callers outside task_manager.cpp, and the restore core touches neither task_manager_ nor promotion_*/local_ssd_manager_ (it only reads segment_manager_.HasSegmentByEndpoint). So those sites share no state with the restore and belong at [needs:none]. I will switch them in the annotation PR.

One behaviour question I do want on record before extracting them, since @Icedcoco asked to preserve externally visible behaviour: today those calls block for the whole restore window because the core holds master_state_mutex_ exclusively. After extraction they would run during recovery — data-safe, but a client could fetch or complete a task while the master is rebuilding metadata. If that is acceptable, [needs:none] is exactly right and the extraction is a pure removal; if not, the extraction needs the lifecycle gate. I would rather state the choice in the extraction PR than encode it as a third tag — unless you would prefer the tag.

On the "also touches" verification: for the task pair I already checked it — the secondary touch is the client liveness record (FindClientRecord + TryAcquireServing/RetainingGuard), and that is pinned by the guard, not by the global lock. I will do the same check per cluster before each extraction, starting with PutEnd's oplog append and offload hand-off, since those are the ones where a secondary ordering dependency is most plausible.

On remount and the group_id idea: happy to bring that as the next design discussion, but I would sequence it after the first extraction so it lands against working code. One input that constrains it: RFC #2979 makes SegmentView the authority for segment information and states "No SegmentId reuse. New mounts receive a new client-generated UUID", so the remount publish phase should reuse SegmentView rather than introduce a parallel generation — and the group_id linearization idea should be checked against that before we commit to it.

<!-- gh-comment-id:6292644915 --> @kunkunblueberry commented on GitHub (Oct 20, 2026): Two replies, one per thread. **@Icedcoco — the restore lifecycle prototype already exists as a PR: #4736.** It is the focused slice you described, and it is rebased onto current `main` (`95750b2c`). On your three asks: - *Lock sites changed*: none, deliberately. Slice 1 adds the lifecycle state machine and worker admission and changes zero locking behaviour — `master_state_mutex_` stays exactly where it is. I made that split on purpose so the state machine can be reviewed without arguing about lock removal at the same time. The lock-site work starts with the annotation PR below, then one extraction. - *Concurrent restore admission*: `RestoreLifecycleOverlappingRestoreIsRefused`. It holds the restore core's exclusive lock through the test peer, so `Recovering` is published while the core cannot progress; the overlapping call must then be refused at admission. This is a deterministic window, not a timing race (an earlier version of the test was timing-dependent and a reviewer correctly rejected it). - *Failure / cleanup paths*: `RestoreLifecycleFailedRestoreReachesFailed`, `RestoreLifecycleRetryAfterFailedRestoreReachesReady` (replacement-restore retry from `Failed`), `RestoreLifecycleRestoreAfterServingIsRefused`, and `RestoreLifecycleWorkersPausedOnlyInRestoreWindows` for the worker gate. Locally on the rebased tree: lifecycle 9/9, `master_service_test` 71/71, `master_service_ha_test` 127/130 with the same three environment-only `BatchEvict*` failures documented in the PR. **@he-yufeng — the two-axis tagging is what I built, and you are right about `[needs:none]`; I had it wrong.** Correction first, because it changes the annotation: I originally tagged the task and promotion sites `[needs:lifecycle-order]` on the premise that the restore clears the task table via `TaskManagerSerializer::Reset()`. I checked before writing this: `TaskManagerSerializer` has no callers outside `task_manager.cpp`, and the restore core touches neither `task_manager_` nor `promotion_*`/`local_ssd_manager_` (it only reads `segment_manager_.HasSegmentByEndpoint`). So those sites share no state with the restore and belong at `[needs:none]`. I will switch them in the annotation PR. One behaviour question I do want on record before extracting them, since @Icedcoco asked to preserve externally visible behaviour: today those calls *block* for the whole restore window because the core holds `master_state_mutex_` exclusively. After extraction they would run during recovery — data-safe, but a client could fetch or complete a task while the master is rebuilding metadata. If that is acceptable, `[needs:none]` is exactly right and the extraction is a pure removal; if not, the extraction needs the lifecycle gate. I would rather state the choice in the extraction PR than encode it as a third tag — unless you would prefer the tag. On the "also touches" verification: for the task pair I already checked it — the secondary touch is the client liveness record (`FindClientRecord` + `TryAcquireServing/RetainingGuard`), and that is pinned by the guard, not by the global lock. I will do the same check per cluster before each extraction, starting with `PutEnd`'s oplog append and offload hand-off, since those are the ones where a secondary ordering dependency is most plausible. On remount and the `group_id` idea: happy to bring that as the next design discussion, but I would sequence it after the first extraction so it lands against working code. One input that constrains it: RFC #2979 makes SegmentView the authority for segment information and states "No SegmentId reuse. New mounts receive a new client-generated UUID", so the remount publish phase should reuse SegmentView rather than introduce a parallel generation — and the `group_id` linearization idea should be checked against that before we commit to it.
Author
Owner

@kunkunblueberry commented on GitHub (Oct 27, 2026):

The annotation PR is up: #4994 (comments only, 94 added lines, no synchronization change). Two things in it correct what I said earlier in this thread, so they belong here rather than only in the PR body.

1. The task cluster does not come out as one clean extraction. I had put the task and promotion sites at [needs:none] on the grounds that the restore core touches neither task_manager_ nor promotion_*. That is true, but it answers a different question: [needs:lifecycle-order] is about ordering against the segment lifecycle. Checking the code, CreateCopyTask and CreateMoveTask do resolve segment names — ExistsSegmentName(target) for every target, and GetClientIdBySegmentName(selected_source_segment, ...) on submission. So all four Create*Task sites are [needs:lifecycle-order], and only FetchTasks, MarkTaskToComplete and TaskCleanupThreadFunc are [needs:none]. The first extraction from the task cluster is therefore 3 sites, not 9; the other 6 need the registry-generation pin first.

2. The same check moved two more off [needs:none]. PromotionAllocStart allocates from the segment registry, and the drain pair splits: MaybeCompleteDrainJob resolves segments, ScheduleDrainJobTasks scans object metadata across keys.

Current distribution over 63 acquisition sites (4 of them unique_lock):

inv none lifecycle-order cross-shard-view
object-metadata 25 3 5
segment-registry 0 5 0
task-registry 3 5 1
promotion-state 5 1 0
local-ssd 3 1 0
eviction 3 0 0
client-registry 1 1 0
restore-window 0 1 0
total 40 17 6

On the first extraction, the [needs:none] set is now explicit, so the remaining question is which cluster to take. I am leaning toward [inv:eviction] (3 sites: EvictNamespaceMemory, BatchEvict, EvictManagedWeightMembersToCold) because after #4887 that path runs through memory_eviction_->evictor, which carries its own per-key synchronization — if that holds, the global lock there is a pure removal rather than a removal plus a new mechanism. I will verify that before opening anything, and I am happy to take a different cluster if you would rather start with the task or local-SSD sites.

One sequencing note: #4801 and #4802 are already working on the remount path, so I am leaving that domain to them.

<!-- gh-comment-id:6437033109 --> @kunkunblueberry commented on GitHub (Oct 27, 2026): The annotation PR is up: #4994 (comments only, 94 added lines, no synchronization change). Two things in it correct what I said earlier in this thread, so they belong here rather than only in the PR body. **1. The task cluster does not come out as one clean extraction.** I had put the task and promotion sites at `[needs:none]` on the grounds that the restore core touches neither `task_manager_` nor `promotion_*`. That is true, but it answers a different question: `[needs:lifecycle-order]` is about ordering against the *segment* lifecycle. Checking the code, `CreateCopyTask` and `CreateMoveTask` do resolve segment names — `ExistsSegmentName(target)` for every target, and `GetClientIdBySegmentName(selected_source_segment, ...)` on submission. So all four `Create*Task` sites are `[needs:lifecycle-order]`, and only `FetchTasks`, `MarkTaskToComplete` and `TaskCleanupThreadFunc` are `[needs:none]`. The first extraction from the task cluster is therefore 3 sites, not 9; the other 6 need the registry-generation pin first. **2. The same check moved two more off `[needs:none]`.** `PromotionAllocStart` allocates from the segment registry, and the drain pair splits: `MaybeCompleteDrainJob` resolves segments, `ScheduleDrainJobTasks` scans object metadata across keys. **Current distribution over 63 acquisition sites** (4 of them `unique_lock`): | inv | none | lifecycle-order | cross-shard-view | | --- | --- | --- | --- | | object-metadata | 25 | 3 | 5 | | segment-registry | 0 | 5 | 0 | | task-registry | 3 | 5 | 1 | | promotion-state | 5 | 1 | 0 | | local-ssd | 3 | 1 | 0 | | eviction | 3 | 0 | 0 | | client-registry | 1 | 1 | 0 | | restore-window | 0 | 1 | 0 | | **total** | **40** | **17** | **6** | **On the first extraction**, the `[needs:none]` set is now explicit, so the remaining question is which cluster to take. I am leaning toward `[inv:eviction]` (3 sites: `EvictNamespaceMemory`, `BatchEvict`, `EvictManagedWeightMembersToCold`) because after #4887 that path runs through `memory_eviction_->evictor`, which carries its own per-key synchronization — if that holds, the global lock there is a pure removal rather than a removal plus a new mechanism. I will verify that before opening anything, and I am happy to take a different cluster if you would rather start with the task or local-SSD sites. One sequencing note: #4801 and #4802 are already working on the remount path, so I am leaving that domain to them.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
kvcache-ai/Mooncake#1197
No description provided.