mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #3157] [RFC]: Lock-Free Tenant Quota Charge/Release with Unified Charged Bytes #810
Labels
No labels
ascend
auto-closed
bug
bug
CI/Build
Common
documentation
enhancement
feature request
good first issue
help wanted
high priority
MetaX
nightly-failure
pull-request
PyTorch Backend
question
RFC
Roadmap
stale
Store
Tests
Transfer Engine
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/Mooncake#810
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @Lin-z-w on GitHub (Jul 28, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/3157
Changes proposed
Changes proposed
Summary
This RFC proposes replacing Mooncake Store's per-tenant
used_bytesandreserved_bytesquota-accounting state with one atomiccharged_bytescounter.Memory is charged when Put, Copy, Move, or promotion admission starts. A successful operation keeps the charge, while a failed, revoked, partially completed, or expired operation refunds the unused portion. Completion no longer moves bytes from a global reserved counter to a global used counter.
The tenant quota control plane remains lock-based. Policy persistence, effective-quota recomputation, tenant registration, snapshots, and administrative mutation continue to use the existing control-plane locks. Only the high-frequency charge and release operations become independent of the quota-table shard mutex.
In this RFC, "lock-free" describes only the tenant quota charge/release primitive. A complete Put, Copy, Move, or promotion still uses its existing metadata, allocator, and task locks. Move is included because it shares the same
ReplicationTaskquota-settlement path as Copy; this RFC does not otherwise broaden the lock-free scope.The proposed design:
pending_charge_bytesfor exact rollback;TenantState;Motivation
The current quota state distinguishes:
Admission checks:
Reserve,Commit,CommitAdditional,Abort,Release, andReleasePartialall acquire the tenant quota shard mutex. Although the quota table is sharded, every operation for the same tenant maps to the same shard and therefore serializes on one mutex. A large tenant writing keys distributed across many metadata shards can still be bottlenecked by its single quota mutex.The global reserved/used distinction is not required for quota admission. Admission only needs the total number of bytes already charged to the tenant. The distinction is useful when settling one operation, but that information is already present in
ObjectMetadata,ReplicationTask, andPromotionTask.Combining both global counters allows admission to become:
Completion normally performs no aggregate byte update. Failure and partial completion only subtract the corresponding operation-local pending charge.
Goals
Non-goals
Terminology
charged_bytesis an accounting term. A charge can be installed immediately before physical allocation and refunded if allocation fails. It does not mean that every charged byte is already readable or physically allocated at the instant a metrics request observes it.Current behavior
The current quota state machine performs the following transitions:
The quota table lock provides three different kinds of protection:
Removing
reserved_byteseliminates the second requirement, but it does not by itself solve policy deletion or tenant-entry lifetime. The proposed design addresses those requirements explicitly.Proposed semantics
Unified charge
The tenant aggregate becomes:
The state transitions become:
PutStart therefore has the same admission behavior as the current
Reserve. The visible difference is that the aggregate counter is immediately reported as charged rather than split between used and reserved.Breaking API and metric changes
This RFC is an intentional breaking refactor. The implementation removes the old tenant-ID-based
Reserve,Commit,CommitAdditional,Abort,Release, andReleasePartialaccounting API and replaces it with handle-basedTryChargeandReleaseplus operation-settlement helpers.The admin response and Prometheus surface remove
used_bytes,reserved_bytes,committed_count, andmetadata_object_count. They exposecharged_bytes, effective and requested quota, and admission state directly. No field aliases, zero-valued placeholders, dual accounting mode, or compatibility feature flag are provided.The change must therefore be deployed as one control-plane cutover rather than as a mixed-version rolling upgrade.
Operation-local pending charge
This RFC removes
reserved_bytesfrom the tenant quota account. It does not remove knowledge of the charge owned by an in-flight operation.The existing fields are renamed:
They remain in:
ObjectMetadata;ReplicationTask; andPromotionTask.These fields are protected by the existing metadata-shard lock. End, Revoke, cleanup, and durable-finalization paths consume them exactly once, preferably through a centralized helper using
std::exchange(field, 0).Without operation-local pending charge, an aborting path cannot determine the correct refund after partial completion, replica loss, Upsert replacement, or promotion timeout.
Quota account representation
Atomic charge state
The preferred representation is:
Encoding the admission gate and byte count in one atomic word gives policy deletion and admission a single, exact ordering point.
This reserves the high bit and limits one tenant to
2^63 - 1charged bytes, approximately 8 EiB. The limit is far above practical Mooncake deployments, but it is an observable validation change and must be documented. Policy input, capacity recomputation, requested replica charge, and rebuild code must reject values above this limit rather than truncate them.Policy sequence
policy_sequence_is even while the policy is stable and odd while an effective quota or registration state is being changed.A charge operation records the sequence before its CAS and checks it again afterward. If the sequence changed, the operation refunds its charge and retries against the new policy.
This prevents an old request from succeeding across:
Only control-plane updates write the sequence, so it is not a contended data-plane CAS location.
Which fields require atomic operations
Only
charged_state_needs a compare-and-swap loop for quota correctness. The other fields do not form one atomic quota equation:charged_state_effective_quota_bytes_policy_sequence_committed_countandmetadata_object_countare removed rather than made atomic. They are not required for admission, release, or policy deletion when the control plane can perform an authoritative metadata scan.The hot-path quota interface is intentionally small:
Neither method accepts a tenant ID or accesses the account registry. Tenant-ID-based lookup, policy upsert/delete, effective-quota recomputation, listing, and admin snapshots remain control-plane interfaces protected by the existing locks.
Stable account lifetime
Quota accounts are stored behind stable pointers:
An account is not freed or erased while the Master is running. Removing a policy leaves a closed tombstone. This avoids RCU, hazard pointers, and shared-pointer reference-count traffic in charge/release paths.
Each metadata-shard
TenantStatestores the handle:All
TenantStateinstances for the same tenant point to the same quota account. A normal write obtains the handle while validating the tenant policy and binds it when creating the per-shardTenantState. Existing-object paths reuse the bound handle and do not perform a quota-map lookup.Creating the first
TenantStatefor a tenant in one metadata shard may take the existing quota-registry shard lock to resolve or create the stable handle. This is a cold lifetime event, not part ofTryChargeorRelease. If the same workload repeatedly removes and recreates otherwise-emptyTenantStates, that lookup may still appear in profiles; avoiding it would require retaining per-shard tombstones or a lock-free registry and is outside this RFC.After a charge succeeds, every owner that may refund it must retain access to the same handle. Normally the owning
TenantStateprovides it. Any deferred callback or cleanup record that can outlive removal of thatTenantStatemust captureTenantQuotaHandledirectly. Hot settlement code must not fall back to a tenant-ID registry lookup.Tombstone memory grows with the number of distinct tenant IDs seen during the Master process lifetime. The first implementation accepts this tradeoff. Bounded reclamation can be added later if high-rate tenant churn becomes a real deployment requirement.
Admission algorithm
The charge result includes the deficit observed by the failing attempt:
At a high level,
TryCharge(bytes)performs:The overflow-safe admission check is:
Successful CAS is the admission linearization point.
Illustrative pseudocode, with error plumbing omitted, is:
Every control-plane change that can alter registration or admission semantics must bracket its atomic stores and admission-gate mutation by changing
policy_sequence_from even to odd and back to even. Control-plane writers are already serialized by the existing locks.For a registered tenant, a zero-byte admission continues to succeed even when the account is over quota. It still verifies that admission is open and that the policy sequence is stable.
Returning the deficit from the failed charge attempt replaces the current separate
ComputeDeficitlookup in the Put eviction/retry path. The deficit is a point-in-time eviction target; concurrent release can make it conservative, which is already acceptable to the retry loop.Release algorithm
Release must work even when admission is closed, because orphaned or administratively disabled accounts still need to drain.
A CAS loop is preferred over unchecked
fetch_subso an accounting bug cannot underflow the byte field and corrupt the admission bit.Illustrative release pseudocode is:
No registration or effective-quota check is performed during release.
Control-plane behavior
The existing control-plane locking remains:
tenant_quota_policy_mutex_serializes policy persistence and tenant registration changes;tenant_quota_recompute_mutex_serializes the capacity snapshot with effective-quota recomputation;Charge and release methods accept a
TenantQuotaHandleand do not acquire any of these quota-table locks.Effective-quota update
An effective quota update uses a sequence write:
An admission completed before the odd sequence begins can be linearized before the update. An admission overlapping the update detects the sequence change, refunds, and retries.
Delete policy if empty
Policy deletion is a control-plane operation and may take the existing policy and metadata locks. It does not need data-plane counters.
Under
tenant_quota_policy_mutex_, deletion:TenantHasObjects(tenant_id)metadata scan while admission remains closed;Admission close uses:
There are only two outcomes:
TENANT_NOT_EMPTY; orTENANT_NOT_REGISTERED.If
TenantHasObjectsfinds any metadata, or if policy persistence fails, deletion opens another short sequence window, restores the policy, reopens admission, completes the sequence update, and returns an error. The scan holdstenant_quota_policy_mutex_and visits metadata shards one at a time, consistent with the existing lock order.Odd sequence windows contain only in-memory atomic and policy-field updates. Connector I/O and metadata scans run with admission closed but the sequence even, so
TryChargereturnsTENANT_NOT_REGISTEREDinstead of spinning for the duration of a slow control-plane operation.Connector policy removal with existing objects
Applying a connector snapshot can remove an explicit policy while metadata still exists. This creates an orphan account rather than deleting its state.
The control plane atomically sets the admission-closed bit while preserving the low charged-byte bits. Existing charges remain visible and releasable, but new admissions fail. Re-registering the tenant updates its effective quota, advances the policy sequence, and reopens admission.
Zero-charge metadata
NOF-only and local-disk-only metadata may have zero memory quota charge.
charged_bytestherefore cannot by itself prevent policy deletion from racing with creation of such metadata.The first implementation retains the current slow-path rule:
tenant_quota_policy_mutex_until metadata insertion completes; andPolicy deletion closes admission and scans authoritative metadata while holding the same outer lock. It therefore cannot miss a concurrent zero-charge creation. This RFC does not add an object counter or a data-plane reader counter solely to accelerate this cold administrative path.
Every metadata-creation path must satisfy one of two publication rules:
tenant_quota_policy_mutex_remains held from policy validation until the new metadata is visible.A path that initially charges positive bytes but refunds the charge to zero before publishing metadata must use the second rule. It must acquire the policy mutex before the metadata-shard lock, or drop the inner lock and retry through the protected creation path; it must not invert the existing lock order. This prevents policy deletion from closing admission, observing an empty tenant, and then being followed by publication of uncharged metadata.
Operation settlement
Put and new-object Upsert
PutStart:
TryCharge(requested).ObjectMetadata::pending_quota_charge_bytes.PutEnd:
committed_quota_charge_bytes.pending - newly_committed.Completion does not add newly committed bytes to the tenant aggregate; they were already charged at Start.
PutRevoke, stale-processing cleanup, and object erase refund the remaining pending amount exactly once.
Same-size Upsert
Same-size Upsert reuses existing memory buffers and does not add quota charge. The existing committed charge remains installed while replicas temporarily move from COMPLETE to PROCESSING.
Size-changing Upsert
Size-changing Upsert preserves the current peak-accounting behavior:
If new allocation fails after old metadata has been removed, both the failed new charge and the no-longer-owned old charge are released.
If the replacement succeeds, PutEnd:
pending_replaced_quota_charge_bytes; andCopy
CopyStart charges:
CopyEnd keeps the charge for successfully completed targets and refunds failed or missing targets. It adds the successful portion to the object's committed charge but does not modify aggregate charged bytes again.
CopyRevoke and replication-task expiry refund the full remaining pending charge.
Move
MoveStart temporarily charges the target allocation while the source remains charged. On successful completion, the source's existing committed charge becomes ownership of the target and the entire temporary pending charge is refunded.
The object's total committed charge therefore remains unchanged after a same-sized Move.
Move failure, revoke, and expiry also refund the temporary pending charge.
Promotion
Promotion allocation charges one object size before allocating a MEMORY replica.
On success, the pending charge becomes committed without another aggregate increment. On failure, notification loss, replica loss, or reaper expiry, the pending charge is refunded.
Replica removal and eviction
Partial replica removal computes completed memory charge before and after the metadata mutation. The difference is released from both the object's committed charge and the tenant aggregate.
No tenant-level committed-object counter is updated. The object's
committed_quota_charge_bytesremains the authoritative ownership record used for subsequent release and reconciliation.Centralized settlement helpers
The implementation should centralize settlement rather than reproduce charge arithmetic across every error path:
All Put, Copy, Move, promotion, erase, durable-finalize, and reaper paths must use these helpers.
Snapshot and metrics semantics
Snapshot consistency
The replacement admin snapshot is:
Quota snapshots atomically load:
Requested quota and explicit-policy state are read under the existing control-plane lock. The resulting snapshot is point-in-time approximate across fields. This is sufficient for admin and Prometheus output. Policy deletion does not rely on this snapshot; it uses the admission gate and authoritative metadata scan.
over_quotabecomes a derived value:An orphan account is represented explicitly by
has_explicit_policy == falseandadmission_closed == true, including when its objects have zero memory charge.Breaking metrics replacement
Add:
Remove the old used, reserved, committed-count, and metadata-object-count quota metrics and response fields in the same change. Dashboards and clients must move directly to
charged_bytesandadmission_closed.The old used/reserved split is intentionally not reconstructed. If operators later need in-flight visibility, it should be added as telemetry derived from operation state and must not become a second quota source of truth.
HA, snapshot restore, and rebuild
charged_state_, quota handles, and operation-local pending charge are derived runtime state and are not added to the snapshot or oplog wire format.Startup remains:
Rebuild:
TenantStateto the account;Rebuild must run while request processing is quiescent. Runtime code must not overwrite
charged_state_while charge or release operations are active.All metadata-creation paths must bind a quota handle, including:
MetadataAccessorRW::Create;Quota accounting remains derived from metadata, so the new runtime counters are not serialized. However, this RFC does not guarantee mixed-version operation or downgrade: the quota admin API, metrics, and internal interfaces change in one cutover.
Code structure
mooncake-store/include/tenant_quota.hTenantQuotaAccount, handle, charge-result, and minimal snapshot types.mooncake-store/src/tenant_quota.cppmooncake-store/include/tenant_quota_sharded.hmooncake-store/include/tenant_quota_sharded_impl.hmooncake-store/include/master_service.hTenantState; replace reserved fields with pending charge ownership; add settlement helpers.mooncake-store/src/master_service.cppmooncake-store/src/master_admin_service.cppmooncake-store/tests/tenant_quota_test.cppmooncake-store/tests/master_service_tenant_quota_test.cppCorrectness invariants
The implementation must maintain:
The low bits of
charged_state_never exceedkMaxChargedBytes.A successful positive admission has one CAS linearization point.
Policy deletion and a new positive admission cannot both succeed.
Admission-closed accounts reject charge but accept release.
Every pending charge has exactly one owner in metadata or a task.
Every pending charge is consumed exactly once by success, failure, revoke, erase, durable finalization, or reaper expiry.
Object committed charge equals charge for its completed MEMORY replicas after an operation settles.
After the system becomes quiescent:
After restart or failover, pending charge is zero and charged bytes equal completed MEMORY charge reconstructed from restored metadata.
Zero-charge metadata prevents successful policy deletion.
New metadata is published only while protected by a positive charge or
tenant_quota_policy_mutex_.Testing plan
Quota-account unit tests
2^63 - 1boundary behavior is explicit.MasterService tests
TenantHasObjects, and reopens admission when deletion is rejected.Concurrency and performance validation
Implementation and cutover plan
The work can be split into reviewable commits, but the final branch is deployed as one breaking control-plane cutover:
A test-only reconciliation pass should compare atomic charged bytes against:
There is no dual accounting implementation, compatibility feature flag, or mixed-version mode. Operators must update quota API consumers and dashboards as part of the same deployment.
Performance expectations
For one tenant, the existing implementation serializes quota updates through a mutex. The proposed design replaces that critical section with a CAS loop over one cache line.
This removes scheduler blocking and mutex handoff, but it does not remove cache-line contention. At very high core counts, a single tenant's exact global quota is inherently a shared synchronization point.
If the charged-state cache line remains a bottleneck after this change, a future RFC can consider distributing quota credits to metadata shards and refilling them from a global account in batches. That design has more complex fairness, reclamation, and policy-update semantics and is intentionally not part of this RFC.
Alternatives considered
Keep reserved bytes and add an atomic total
An atomic
used + reservedtotal could drive admission while the old counters remain for observability. This reduces semantic change, but every operation must update multiple counters and exact snapshots still require locking or a sequence protocol. It also retains two sources of truth for the same quota.Make used and reserved independently atomic
Admission cannot atomically check and update the sum of two independent counters. Concurrent commits and reservations can observe inconsistent pairs, so another combined counter or a 128-bit atomic is still required.
Keep tenant object and committed counters
Tenant-level counters make policy deletion and some metrics O(1), but every object creation, removal, and committed-charge transition performs another cross-shard atomic update. Because policy deletion is a cold administrative path and already performs an authoritative metadata check, this RFC removes the counters and pays the scan cost there.
Separate atomic admission gate and reader counter
Policy deletion could close a separate gate and wait for active admission readers to drain. This preserves the full 64-bit byte range, but adds at least two additional atomic operations to every charge attempt and introduces a second contended cache line.
128-bit compare-and-swap
A 128-bit state can hold the full byte count, registration state, and policy generation. Lock-free 128-bit atomics are not uniformly guaranteed across Mooncake's supported platforms and toolchains; some implementations fall back to a library lock. The proposed 64-bit state has more predictable behavior.
Keep the quota shard mutex
This remains the simplest correctness model and may be sufficient for tenants with low write concurrency. It does not address the target workload where one tenant writes across many metadata shards.
Lock-free tenant registry with reclamation
RCU or hazard-pointer lookup would also remove the policy lookup lock. The selected scope removes locking only from byte charge/release and uses stable tombstones for lifetime. A general lock-free registry is not necessary for the expected benefit.
Per-shard quota credits
Local credits can reduce cache-line contention further, but require reclaiming unused credits during policy shrink, segment changes, tenant deletion, and failover. CAS on the exact tenant total is a substantially smaller first step.
Risks and mitigations
policy_sequence_validation.Feedback requested
This RFC especially requests feedback on:
charged_bytesis the right operator-facing semantic;2^63 - 1per-tenant limit;Before submitting a new issue...
@github-actions[bot] commented on GitHub (Jul 28, 2026):
Thanks for opening this issue, @Lin-z-w!
99333079A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@github-actions[bot] commented on GitHub (Oct 27, 2026):
This issue has had no activity for 90 days and will be closed in 7 days if there is no further activity. Please comment or react if it should stay open.