Tenant Quota Consistency Design#
Background#
Store-RS already supports tenant-scoped quota policy through metadata-backed tenant policy and runtime enforcement hooks. Today, however, quota enforcement still happens as a request-path preflight inside the client runtime.
Relevant current path:
StoreClient::put_object_with_policy_current(...)incrates/mooncake-store-client/src/client/runtime_io.rsrouted and batch write allocation / publication logic in:
crates/mooncake-store-client/src/client/runtime_io.rscrates/mooncake-store-client/src/client/runtime_write.rs
tenant quota policy model in:
crates/mooncake-store-core/src/route.rs
metadata-backed tenant policy storage in:
crates/mooncake-store-rs-metadata
Current write flow is roughly:
load current route / object state
evaluate quota from that snapshot
reserve local or remote capacity
write bytes into storage segments
publish the new route with route CAS
reclaim previous route allocations
This leaves a time-of-check/time-of-use gap:
two writers can both read the same tenant usage snapshot
both can pass quota admission
both can write bytes
both can attempt route publication
quota correctness depends on timing rather than protocol guarantees
That is acceptable for best-effort quota, but it is not sufficient for hard multi-tenant isolation.
Goal#
Make tenant quota enforcement strongly consistent at the metadata authority level so concurrent writes cannot over-admit tenant storage usage beyond configured quota, except in explicitly bounded transitional states.
The design should:
preserve tenant-scoped policy as the source of truth for limits
work with current Store-RS architecture:
embedded WRH route authorities
metadata-backed tenant policy
allocator reservations and route publication
remote storage-owner tracking
correctly handle:
create
overwrite
delete
conflict retry
client crash during write publication
keep the request-path semantics understandable and debuggable
Non-Goals#
This design does not try to solve:
global fairness / rate limiting
bandwidth shaping
allocator-space accounting beyond quota admission correctness
exact real-time reconciliation of every derived usage view
elimination of all background reconciliation work
redesigning route ownership or WRH authority selection
This design is specifically about hard tenant quota admission for stored objects.
External Design References#
The research across Ceph, Kubernetes, HDFS, Swift, MinIO, SeaweedFS, JuiceFS, and transactional KV systems led to a clear conclusion:
systems that want hard quota semantics perform quota accounting in the same authoritative metadata transaction as object state mutation, with optimistic conflict retry
systems that keep quota checks outside the authoritative commit path usually become best-effort or eventual
The most relevant patterns for Store-RS are:
Kubernetes ResourceQuota
admission-time enforcement against authoritative versioned state
optimistic conflict retry on concurrent updates
HDFS quota enforcement
namespace mutation and quota accounting share a single metadata authority path
FoundationDB transactional counters
read current counter, compute delta, update counter and entity record in one serializable transaction
JuiceFS hybrid practice
hard admission on authoritative state, background repair only for derived accounting / cleanup
This design follows that family of solutions.
Current Architecture Constraints#
From the Store-RS architecture, the current Store-RS write path has these important properties:
StoreClientperforms request-path admission, allocation, transfer, and route publicationRouteDirectoryowns route lookup and route CASroute authority is usually not the metadata backend directly in
EmbeddedWrhmodeallocator reservation and data transfer can happen before the final route CAS commits
storage ownership and route ownership are intentionally decoupled
Those constraints matter because a strict quota design must answer:
where quota usage becomes authoritative
how overwrite delta is computed
what happens if data bytes are written but final publication does not commit
how retries avoid double-charging or double-refunding
Problem Statement#
Why the current flow is insufficient#
Today the runtime can ask “does this tenant still have room?” before writing, but that answer is based on a snapshot that may already be stale by the time route publication happens.
This creates several correctness gaps:
1. Concurrent create overshoot#
Two writers can both observe usage below limit and both admit writes that together exceed quota.
2. Incorrect overwrite accounting#
Quota should be charged on delta:
create:
+new_sizeoverwrite larger value:
+(new_size - old_size)overwrite smaller value:
-(old_size - new_size)or immediate refund to usagedelete:
-old_size
A preflight check on new_size alone is not sufficient.
3. Publish-after-write crash window#
The current write path can write bytes before final route publication succeeds. If the client crashes after writing bytes but before authoritative metadata commit, quota state and stored bytes can diverge.
4. Conflict retry ambiguity#
Retries must not:
charge quota twice
leak reserved quota
reclaim quota for a write that never became authoritative
A correct solution needs idempotent state transitions.
Design Summary#
Introduce a new authoritative tenant quota state in metadata and move quota admission into the same metadata mutation protocol that commits object state.
The recommended model is:
derive the current object accounting state from authoritative metadata
compute the requested quota delta
atomically update:
tenant quota usage state
authoritative object accounting state
route publication intent / committed object metadata
only after that authoritative commit succeeds should the write become visible as committed
if data bytes must be written before final commit, represent that with an explicit reservation / pending-write record rather than with an implicit preflight check
In short:
quota becomes part of the metadata commit protocol
not a separate runtime-local admission hook
Proposed Metadata Model#
1. Tenant quota state#
Add a new metadata entity per tenant scope:
TenantQuotaState {
scope: TenantPolicyScope,
version: u64,
used_bytes: u64,
used_objects: u64,
pending_reserved_bytes: u64,
pending_reserved_objects: u64,
updated_at_ms: u64,
updated_by: String,
}
Notes:
scopeshould initially be tenant-level onlydomain/object_set quota usage can be added later if we want nested enforcement
versionis required for optimistic concurrency / CASpending_reserved_*is only needed if we adopt the reservation-based protocol described below
Recommendation#
Use Option B.
It cleanly separates:
route ownership / replica placement authority
tenant quota accounting authority
This matches the current Store-RS architecture better than forcing hard quota semantics into WRH route authorities.
Phase 1 status#
The Phase 1 metadata foundation now exists in the shared model and in-memory backend:
TenantQuotaStateTenantObjectAccountingTenantQuotaReservationTenantQuotaReservationRequest/TenantQuotaFinalizeRequestbackend trait methods for get/list/reserve/finalize/abort
Current implementation status:
in-memory backend: implemented and covered by unit tests
Redis backend: implemented with backend-atomic Lua reserve/finalize/abort scripts plus quota/accounting lookup helpers
etcd backend: implemented with multi-key compare-and-swap txn loops for reserve/finalize/abort plus quota/accounting lookup helpers
Phase 1 backend parity tests now cover:
create / overwrite / delete reservation state transitions
finalize idempotency
abort idempotency
byte and object limit admission
reservation listing and quota-state lookup
The first implementation remains tenant-root only. TenantPolicyScope values with domain/object-set components are rejected for quota-state mutation APIs until nested quota accounting is implemented.
Write Protocol Options#
There are two realistic protocol families.
Option 1: Atomic metadata commit before data publish#
Flow#
read
TenantQuotaStateandTenantObjectAccountingcompute
delta_bytesanddelta_objectsCAS / transact metadata to:
validate limit
update quota usage
update object accounting state to new committed size/version
create a pending publish marker or next route intent
write bytes to storage
publish route
mark pending publish committed
Why it is not the best fit today#
This gives strong quota semantics, but it means metadata can claim usage before data bytes are durably published. On crash/failure, we then need rollback or orphan recovery anyway.
Given Store-RS currently writes bytes before route publication, this option does not reduce protocol complexity enough.
Option 2: Reservation + finalize/abort protocol#
Summary#
This is the recommended design for Store-RS.
Instead of doing a plain preflight quota check, the writer first obtains an authoritative quota reservation in metadata. That reservation is then either finalized into committed usage or aborted.
New metadata entity#
TenantQuotaReservation {
reservation_id: UUID,
tenant_scope: TenantPolicyScope,
object_key: ObjectKey,
object_version_hint: Option<u64>,
requested_bytes_delta: i64,
requested_objects_delta: i64,
state: Pending | Finalized | Aborted,
expires_at_ms: u64,
created_at_ms: u64,
writer_runtime: ClientRuntimeId,
}
Flow#
Phase A: reserve quota#
read committed
TenantObjectAccountingfor the objectcompute tentative delta against current committed size
metadata transaction atomically:
verifies tenant policy limit
increments
pending_reserved_bytes/pending_reserved_objectsinserts
TenantQuotaReservationversion-checks the object accounting row used for delta calculation
If this step fails, the write is rejected before bytes are written.
Phase B: write bytes and publish route#
reserve storage allocations
write bytes to target segments
publish the route through existing route authority flow
Phase C: finalize reservation#
metadata transaction atomically:
verifies reservation is still
Pendingverifies committed object accounting version still matches expected predecessor
applies the committed object size/version
moves reserved quota into committed usage
decrements
pending_reserved_*marks reservation
Finalized
Abort path#
If route publication fails or the writer gives up:
metadata transaction marks reservation
Aborteddecrements
pending_reserved_*leaves committed usage unchanged
local/remote allocations are released or scheduled for reclaim
Why this fits Store-RS best#
It matches the current architecture:
allocations and data transfer still happen before final route visibility
quota state becomes authoritative in metadata rather than in route authorities
failures become explicit protocol states instead of hidden races
recovery is auditable and repairable
Delta Computation Rules#
Quota correctness depends on consistent delta rules.
Assume authoritative committed state for object O is:
old_sizeold_exists
Then:
Create#
If object does not exist:
delta_bytes = new_sizedelta_objects = +1
Overwrite same object#
If object exists:
delta_bytes = new_size - old_sizedelta_objects = 0
This means:
larger overwrite may require headroom
same-size overwrite needs no quota expansion
smaller overwrite frees quota on finalize
Delete#
If object exists:
delta_bytes = -old_sizedelta_objects = -1
Idempotent retry#
A retry of the same logical write must either:
reuse the same reservation id, or
fail version checks and recompute against latest committed state
The protocol must never assume retries are harmless without version checks.
Failure Handling and Recovery#
This is the main reason to prefer an explicit protocol over a hidden preflight check.
Failure matrix#
1. Reservation fails#
Result:
write rejected
no bytes written
no route published
2. Data write fails after reservation#
Result:
reservation is aborted
pending reservation counters are released
allocations are released
no committed usage change
3. Route CAS fails after data write#
Result:
reservation is aborted
newly written allocations are released or reclaimed
no committed usage change
4. Client crashes after reservation, before finalize/abort#
Result:
reservation remains
Pendingbackground reconciler inspects expired reservations
for each expired reservation:
if no matching committed route/object accounting exists, abort it
if route publication succeeded and object accounting can be proven, finalize it
5. Finalize transaction conflicts#
Result:
retry finalization if the predecessor version still matches
otherwise re-read current object accounting and route state
if another write already committed a newer object version, abort stale reservation and reclaim bytes
Background Reconciliation#
A strict protocol still needs bounded repair for crash recovery.
Add an explicit reconciliation loop or admin-triggered tool for:
expired pending reservations
orphaned written allocations with no finalized reservation
finalized accounting with missing route visibility
route visibility with stale pending reservation state
Initial implementation recommendation#
Keep reconciliation simple and operator-visible:
add admin inspection commands first
background automation can come after the protocol is stable
Suggested admin surface:
mooncake-store-rs-admin quota state --tenant <t>mooncake-store-rs-admin quota reservations --tenant <t>mooncake-store-rs-admin quota abort --tenant <t> --reservation-id <id> [--dry-run]mooncake-store-rs-admin quota reconcile --tenant <t> [--dry-run]
The current implementation also exposes matching HTTP endpoints:
GET /v1/tenant-quotas/<scope>GET /v1/tenant-quotas/<scope>/reservations?state=pending|finalized|abortedPOST /v1/tenant-quotas/<scope>/reservations/<reservation_id>/abortPOST /v1/tenant-quotas/<scope>/reconcile
Current reconcile behavior is intentionally conservative:
expired pending reservations are aborted
pending reservations whose route and object-accounting state already match an authoritative active object are finalized
other mismatches remain inspectable and are reported as skipped for operator review
Reservation retention is now split by lifecycle state:
Pendingreservations are kept until they are finalized or aborted by normal write flow or explicit reconcileFinalizedandAbortedreservations are retained only for a bounded terminal window, then expire from Redis automaticallythe default terminal retention window is
24hterminal retention uses Redis TTL on both the reservation record and its per-tenant index entry so completed history does not accumulate indefinitely
This matches the existing admin philosophy in the multi-tenant admin design. Runtimes now also export tenant-quota reservation/finalize/abort/reconcile counters through the built-in Prometheus metrics registry.
API / Trait Changes#
Metadata traits#
Add new metadata operations in mooncake-store-core and implement them in all backends.
New operations#
get_tenant_quota_state(scope)
compare_and_swap_tenant_quota_state(scope, expected_version, next)
get_object_accounting(key)
compare_and_swap_object_accounting(key, expected_version, next)
create_quota_reservation(...)
finalize_quota_reservation(...)
abort_quota_reservation(...)
list_quota_reservations(scope, filter)
Preferred backend shape#
Where backend capabilities allow it, expose higher-level atomic methods instead of forcing multi-step client orchestration:
reserve_quota_for_object_write(...)
finalize_quota_write(...)
abort_quota_write(...)
This is especially important for Redis / etcd so the client does not emulate multi-key transactions unsafely.
Runtime changes#
StoreClient#
Current enforce_namespace_quota(...) evolves from:
preflight policy check
to:
reservation acquisition / finalize / abort coordinator
runtime_io.rs#
Single-object write path should change from:
check quota n- allocate
write
route CAS
reclaim old route
to:
compute predecessor state
reserve quota in metadata
allocate
write
route CAS
finalize quota reservation
reclaim old route
on failure: abort reservation and reclaim new allocations
runtime_write.rs#
Batch writes should not reserve quota per item independently without metadata protection.
Recommended batch rule:
reserve per object in a deterministic key order, or
add a backend-level batch reservation transaction for a set of object deltas in one tenant
The second option is preferable for throughput, but the first option is easier to ship first.
Batch Write Semantics#
Batch writes need explicit policy because quota admission can partially succeed.
Recommended v1 behavior#
For batch_put and related batch APIs:
process reservations in stable key order
if any reservation fails:
abort already-acquired reservations in the batch
return a conflict / quota exceeded error for the batch
only after all reservations succeed do we proceed to data write and route publication
Pros:
preserves all-or-nothing batch admission semantics
simpler than partial batch acceptance
Cons:
more metadata coordination
This is acceptable for a correctness-first v1.
Delete Path#
Delete must participate in authoritative quota accounting too.
Required flow#
load authoritative object accounting
route delete CAS succeeds
metadata finalization applies negative delta:
used_bytes -= old_sizeused_objects -= 1object accounting moves to
Deleted
reclaim route allocations
Delete should not rely on asynchronous reclaim to refund tenant quota. The quota refund should happen when the delete becomes authoritative, not when bytes are later garbage-collected.
Overwrite Path#
Overwrite is the most important correctness case.
Required invariant#
Quota is charged on committed object delta, not on raw incoming payload size.
Example:
current object size = 64 MiB
max tenant bytes = 100 MiB
current committed usage = 90 MiB
overwrite with 65 MiB should succeed
delta = +1 MiB
overwrite with 120 MiB should fail
delta = +56 MiB
This is one of the main reasons an explicit object accounting row is useful.
Redis / etcd / in-memory backend implications#
Redis#
Redis can support this design if the multi-key reserve/finalize/abort steps are implemented as Lua scripts with version checks.
Needed properties:
atomic read-check-write across:
quota state key
object accounting key
reservation key
deterministic conflict result
no client-side race window between independent Redis commands
etcd#
etcd can support this design through compare-and-swap style transactions over versioned keys.
Needed properties:
compare object accounting version
compare quota state version
write reservation and next state in one txn
In-memory#
In-memory backend should implement the same protocol directly under a mutex for test determinism.
Observability#
Add explicit metrics and logs so quota behavior is diagnosable.
Suggested metrics#
store_quota_reservation_total{result=ok|conflict|error}store_quota_finalize_total{result=ok|conflict|error}store_quota_abort_total{reason=write_failed|route_conflict|expired|repair}store_quota_pending_reservationsstore_quota_used_bytesstore_quota_used_objectsstore_quota_reconcile_total{result=finalized|aborted|error}
Suggested logs#
Every reservation/finalize/abort should log:
tenant
object key
reservation id
predecessor version
delta bytes / objects
final outcome
Rollout Plan#
Phase 0: design-only preparation#
add this design doc
align on metadata model and semantics
decide whether object accounting is route-derived or separate
Phase 1: metadata model and admin inspection#
add
TenantQuotaStateadd
TenantQuotaReservationadd
TenantObjectAccountingadd backend support in in-memory, Redis, and etcd
add admin read-only inspection commands
Phase 2: single-object strict quota path#
implement reserve/finalize/abort for single
putimplement delete refund path
gate behind an opt-in runtime/config flag if needed during bring-up
add crash/retry tests
Current single-object runtime behavior#
For positive-delta writes, the current single-object path adds a bounded tenant-local recovery step on top of metadata-authoritative reserve/finalize/abort semantics:
quota policy resolves from
LogicalObjectId.scope.tenant, not only from the writer’s default tenantwhen reservation returns
tenant quota bytes exceededortenant quota objects exceeded, runtime may evict one older object from the same tenant and retryrecovery is bounded by
MAX_TENANT_LOCAL_EVICTION_ATTEMPTSvictim selection stays tenant-local: skip the object being written, consider only
RouteState::Active, and order candidates by older version first and then larger committed lengthif no same-tenant victim can free enough room, the original quota conflict is returned unchanged
cross-tenant data is never evicted to satisfy another tenant’s write
This keeps strict quota semantics intact: metadata reservation/finalize is still authoritative, and self-eviction only frees capacity inside the same tenant before the next retry.
Phase 3: batch strict quota path#
implement deterministic multi-object reservation flow
add batch rollback / abort semantics
add conflict-heavy tests
Phase 4: reconciliation automation#
background cleanup for expired reservations
admin-triggered repair commands
metrics / dashboards
Phase 5: remove legacy best-effort path#
stop treating runtime-local preflight quota checks as authoritative
keep them only as early rejection hints, or remove them entirely
Alternatives Considered#
1. Keep current preflight check and add more retries#
Rejected.
Why:
retry reduces visible failures
it does not eliminate quota TOCTOU
concurrent writers can still over-admit quota
3. Use only periodic reconciliation/scanner-based enforcement#
Rejected.
Why:
this becomes best-effort quota, not strict quota
unsuitable for hard multi-tenant isolation
4. Use distributed local credits from day one#
Deferred.
Why:
improves scalability under hotspot tenants
but adds bounded over-reservation / debt complexity
premature before we prove a single tenant quota row is a bottleneck
Open Questions#
Should the first strict implementation cover only tenant-level quota, or also domain/object_set scopes?
Should object accounting reuse route metadata for committed length, or keep a distinct object-accounting row? This doc recommends a distinct row.
Do we need a separate logical write id to make finalize/abort fully idempotent across client retries?
For batch writes, do we want all-or-nothing semantics only, or eventually partial acceptance with per-item results?
Should the initial reconciler be admin-driven only, or should we also run a lightweight background worker in the client/runtime?
Do we want the old
enforce_namespace_quota(...)hook to remain as an early fast-fail hint even after strict metadata reservation exists?
Recommended Decision#
Adopt a metadata-authoritative reservation + finalize/abort quota protocol with these principles:
tenant policy remains the source of quota limits
metadata becomes the source of truth for quota usage
object accounting is explicit and versioned
route publication stays in the existing WRH authority path
the user-visible write only succeeds after both route publication and quota finalization succeed
background reconciliation is used only for crash recovery and cleanup, not for normal correctness
This gives Store-RS a quota model aligned with the mature systems that provide hard guarantees, while still fitting the current split between metadata, route authority, allocator, and storage owner.