Skip to content

[Store] Tenant-first object-level metadata locking (flat group) - #3822

Closed
CAICAIIs wants to merge 64 commits into
kvcache-ai:mainfrom
CAICAIIs:tenant-first-object-locking-3670
Closed

CAICAIIs wants to merge 64 commits into
kvcache-ai:mainfrom
CAICAIIs:tenant-first-object-locking-3670

Conversation

@CAICAIIs

@CAICAIIs CAICAIIs commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Description

Implements object-level metadata locking for the Mooncake Store (issue #3670, flat-group model from #3589).

Today a single coarse shard lock serializes all writes and eviction on a shard, so unrelated objects contend on one lock. This PR replaces it with a per-object lock (ObjectEntry::mutex) plus a per-tenant object route: an object is excluded only while it is itself being read, written, or evicted. Group semantics keep the routing decoupling of #3589 in a thinner form — a group is a membership table plus one shared Lease (GroupIndex), consulted only by eviction for all-or-none decisions; the read path never touches the group table.

New modules under mooncake-store/include/tenant/:

  • MetadataCatalog — boundary between MasterService and the tenant module; owns the tenant registry and lifecycle rules.
  • TenantDirectory — copy-on-write TenantId → TenantCatalog map; lock-free reads, atomic get-or-create.
  • TenantCatalog — per-tenant aggregate: quota handle, ObjectIndex, GroupIndex.
  • ObjectIndex — flat key → shared_ptr<ObjectEntry> route with identity-checked erase (EraseIf), publication generations for remove/recreate races, and the dynamic-replication lease table.
  • ObjectEntry — per-object mutex guarding the metadata envelope and all per-key task state that previously lived in parallel shard maps.
  • ObjectMetadata / object_replica_list.h — extracted metadata envelope (identity, replicas, lease, soft-pin, quota ledger).

Concurrency rules: route locks are never held together with object locks (strong handles bridge the gap), teardown is claimed once per entry (is_torn_down), and a route slot is erased only against the exact entry being torn down, so a concurrent remove + re-create cannot drop a live object.

Module

  • Transfer Engine (mooncake-transfer-engine)
  • Mooncake Store (mooncake-store)
  • Reshard (mooncake-reshard)
  • Mooncake EP (mooncake-ep)
  • Mooncake PG (mooncake-pg)
  • Integration (mooncake-integration)
  • P2P Store (mooncake-p2p-store)
  • Python Wheel (mooncake-wheel)
  • Common (mooncake-common)
  • Mooncake RL (mooncake-rl)
  • CI/CD
  • Docs
  • Other

Type of Change

  • Bug fix
  • New feature
  • Refactor
  • Breaking change
  • Documentation update
  • Performance improvement
  • Other

How Has This Been Tested?

Unit / regression suites (all pass): master_service_test (69), master_service_ha_test (93), tenant_store_test (15), tenant_directory_test (4), object_entry_test (1), plus new group_index_test, object_index_test, tenant_catalog_test, tenant_directory_test.

Performance A/B (Debug builds, same machine and compiler, same workload). baseline = pre-PR master (ca09c18d, shard locks), this PR = 7960b555. 2x4GiB segments, 1MiB values, fixed arrival, groups of 5 consecutive put keys share one group_id, eviction driven to the 0.8 high-watermark on both.

Metadata plane (client→master RPC, master_synthetic_bench), 60s per run:

workload ref p50 (ms) p99 (ms) p999 (ms) item_qps rpc_fail
medium baseline 1.608 58.68 104.10 2173.7 148
medium this PR 1.595 8.87 10.79 2173.7 148
high baseline 1.743 61.39 106.95 4347.5 390
high this PR 1.503 8.63 10.95 4347.5 390

p99 -84.9% / -85.9%, p999 -89.6% / -89.8%; p50 -0.8% / -13.7%. rpc_failure_events align on both sides (148 vs 148, 390 vs 390) and item_qps are identical, so the comparison is same-load. The shard-lock baseline tail is also unstable across runs (medium p99 varied 58-79 ms between rounds); this PR stays in a 9-13 ms band.

Isolation under churn. One client writes 1MiB objects at full speed to keep eviction continuously active; a second client runs a mixed read/write workload against the same master. Victim p99: baseline 52.9 → 85.9 ms under churn; this PR 28.6 → 46.4 ms. Under churn, this PR's victim tail stays below even the baseline's uncontended p99 (46.4 vs 52.9 ms).

2x load, 16 workers (metadata plane, fixed arrival at ~8695 item/s on both): baseline p99 105.5 ms, this PR 12.4 ms; rpc_failure_events identical (876 both). This PR's p99 stays in the 9-13 ms band from 1x to 2x load.

Memory cost. Master RSS measured over a 90s linear small-object write (~1M objects): baseline ~0.8 KB/object, this PR ~1.2 KB/object (+~0.4 KB, about +50%). The increase is the per-object lock, generation and task state folded into ObjectEntry, plus the route key stored next to ObjectMetadata::user_key — roughly 4 GB of master RSS at 10M objects. This is the cost of the per-object locking model; compaction (single key storage, on-demand task allocation) is left as follow-up work.

End-to-end runs (store_kv_bench, real data movement through the transfer engine, eviction continuously active) show the same direction: throughput 1800 → 2788 MiB/s and p99 52.9 → 28.6 ms. Note the store bench is closed-loop, so those two runs are not same-load; the metadata-plane tables above are the strict comparison. Raw logs available on request.

The branch is rebased onto current upstream/main; tenant module headers live under include/tenant/.

Checklist

AI Assistance Disclosure

@CAICAIIs
CAICAIIs force-pushed the tenant-first-object-locking-3670 branch 7 times, most recently from b1891db to 8c59b5a Compare September 2, 2026 16:35
Extract ObjectMetadata and the shared per-object runtime types out of
MasterService into independent headers, and introduce the tenant-scoped
container (mooncake::tenant::TenantStore) plus the copy-on-write
TenantDirectory that owns one store per tenant.

The per-object unit now carries its own metadata (owned by the entry) and a
per-object mutex, so unrelated objects never contend on one shard lock.
Fix has_invalid_mem_handle() to guard a null buffer (freed before full
teardown) instead of dereferencing it.
Flip MasterService from sharded metadata maps to a TenantDirectory that owns
one TenantState per tenant, and give each object its own ObjectEntry with a
per-object ObjectEntry::mutex. Concurrent point ops on unrelated objects no
longer share a per-tenant or per-shard lock; the mutation boundary is one
object.

Routing is decoupled from group membership: groups become a thin membership
table plus one shared Lease, single-sourced in each tenant's object route and
consulted only at eviction (all-or-none). The route is a single lock (an
unordered_map key -> strong ObjectEntry handle); the per-object lock and the
route lock are never held together (released before any route mutation), and
group wiring is ordered and rolled back on publish failure so a pinned entry
never observes an un-wired grouped member. lease_ access is uniformly guarded
by ObjectMetadata's own lock.

Gate tests are adjusted for the object-level boundary and a deterministic
snapshot-barrier hook; the test-only route lock is reached only via a
MasterService friend seam. MasterService teardown serializes RemoveAll under
the exclusive snapshot lock.
@CAICAIIs
CAICAIIs force-pushed the tenant-first-object-locking-3670 branch from ac03eaa to 00aa883 Compare September 3, 2026 09:40
CAICAIIs and others added 2 commits September 3, 2026 18:11
Fold the remaining per-object-locking follow-ups on top of the two core feature
commits (per-tenant module foundation and object-level locking in MasterService):
- rename MetadataShardAccessor -> TenantStateAccessor
- retire shard-era naming, scan-budget param, and bucket-shrink machinery
- drop production-dead accessors, redundant IsGrouped, and tautology tests
- re-pin the Create() winner on a lost insert; fix include hygiene
- relocate the tenant module headers (tenant_directory.h, object_entry_types,
  tenant_id.h) into include/tenant/
- adjust the DFS eviction valid-replica guard and drop the shard-era RemoveAll
  race tests (the tenant-first RemoveAll serializes the scan under an exclusive
  snapshot lock, so that race window no longer exists)

All mooncake-store gate suites pass.
@CAICAIIs
CAICAIIs force-pushed the tenant-first-object-locking-3670 branch from ef67a6a to 0efd9ac Compare September 3, 2026 10:12
CAICAIIs and others added 3 commits September 3, 2026 18:28
… cleanup

Carry out the shard-era naming cleanup that the earlier rebase left in place
(the pure-rename commit was dropped as dead-cosmetic against the migrated
base):

- Rename the MasterService accessor plumbing: GetShard() -> GetTenantAccessor(),
  shard_guard_ -> tenant_guard_, and every TenantStateAccessorRW local/parameter
  named 'shard' -> 'tenant_accessor'.
- Drop the dead SetRemoveAllShardHookForTesting / kv_remove_all_shard_hook_
  test hook: the tenant-first RemoveAll serializes the whole scan under an
  exclusive snapshot lock, so the per-shard-lock-release hook it was built
  around no longer exists and has no call site.
- Rewrite the EvictGroupOrObject header comment (the AB/BA cross-shard
  deadlock text) to the per-object lock + per-tenant-object_route model.
- Refresh stale comments that still described metadata shards / per-shard
  task maps / shard lock ordering; keep the genuinely-shard-based ones
  (getShardIndex compatibility, snapshot 'shards' data format, sharded quota
  table).
…name

The accessor rename rewrote user-facing snapshot 'shards'-format error
messages ('expected binary data for shard', 'Failed to unpack shard data',
'Invalid shard format') to 'tenant_accessor'. These describe the on-wire
snapshot 'shards' key, not the tenant accessor, so restore them.
@CAICAIIs
CAICAIIs marked this pull request as ready for review September 3, 2026 11:52
Copilot AI lite review requested due to automatic review settings September 3, 2026 11:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Implements tenant-first, object-level metadata locking for Mooncake Store by replacing shard-wide serialization with per-object locks and introducing per-tenant routing/group state under a copy-on-write tenant directory.

Changes:

  • Added tenant module primitives: TenantDirectory (COW registry), TenantStore (object route + flat group membership), and ObjectEntry (per-key lock + task state).
  • Refactored MasterService metadata/task tracking to live on ObjectEntry and tenant-scoped containers (removing metadata shards / global group table usage).
  • Updated and added tests/benchmarks to validate tenant routing, group lease semantics, and object-entry behavior.

Reviewed changes

Copilot reviewed 29 out of 31 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
mooncake-store/tests/tenant_store_test.cpp Adds unit tests for tenant object route + flat group membership and leases
mooncake-store/tests/tenant_id_test.cpp Updates include path for relocated tenant headers
mooncake-store/tests/tenant_directory_test.cpp Adds unit tests for COW tenant directory behavior and concurrency
mooncake-store/tests/promotion_on_hit_test.cpp Updates promotion task assertions to pull state from ObjectEntry
mooncake-store/tests/object_entry_test.cpp Adds unit tests for per-object metadata ownership and locking API
mooncake-store/tests/master_service_test_fixture.h Refactors test helpers to tenant-first routing + adds accessor regression helper
mooncake-store/tests/master_service_test.cpp Drops obsolete shard-lock race tests; adds regression for accessor create re-pin
mooncake-store/tests/master_service_tenant_quota_test.cpp Updates quota tests to use tenant directory handles instead of shard accessors
mooncake-store/tests/master_service_processing_key_double_erase_test.cpp Simplifies keepalive key selection under tenant-only container model
mooncake-store/tests/master_service/dsl/scenario.cpp Updates DSL scenario mutations to use per-tenant lookup and per-object locks
mooncake-store/tests/ha/snapshot/snapshot_child_process_test.cpp Updates snapshot tests to tenant directory and route-based lookups
mooncake-store/tests/ha/snapshot/master_snapshot_codec_test.cpp Updates tenant header include path
mooncake-store/tests/ha/master_service_ha_test.cpp Updates HA tests to gate via tenant route lock + snapshot barrier hooks
mooncake-store/tests/file_storage_test.cpp Updates tenant header include path
mooncake-store/tests/dynamic_replication_test.cpp Updates dynamic replication tests for entry-based task state
mooncake-store/tests/CMakeLists.txt Registers new unit test targets for tenant module types
mooncake-store/src/ha/snapshot/local_ssd_codec.cpp Updates tenant header include path
mooncake-store/include/types.h Updates tenant header include path
mooncake-store/include/tenant_quota.h Updates tenant header include path
mooncake-store/include/tenant/tenant_store.h Introduces per-tenant object route + flat group membership + DR lease table
mooncake-store/include/tenant/tenant_directory.h Introduces lock-free read / COW write tenant registry
mooncake-store/include/tenant/object_entry_types.h Extracts shared per-key runtime task types/enums from MasterService
mooncake-store/include/tenant/object_entry.h Adds ObjectEntry with per-object shared_mutex and metadata pointer ownership
mooncake-store/include/replica.h Fixes null-deref risk in has_invalid_mem_handle()
mooncake-store/include/object_metadata.h Extracts ObjectMetadata out of MasterService for tenant module reuse
mooncake-store/include/master_service.h Refactors MasterService to tenant-first routing + per-object locking + new test hooks
mooncake-store/include/local_ssd/manager.h Updates tenant header include path
mooncake-store/include/kv_event/kv_event_publisher.h Updates tenant header include path
mooncake-store/benchmarks/batch_evict_bench.cpp Updates eviction benchmark to walk tenant directory + entries

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@CAICAIIs
CAICAIIs marked this pull request as draft September 3, 2026 12:29
Apply the exact reflow the LLVM 22 format check requires: rewrap the
lock-order comment, drop a stray blank line, and re-wrap the
duration_cast in EraseExpiredDynamicReplicationLeases.
Every scope now uses a single tenant_state reference; the parallel
tenant_accessor aliases (a leftover from the removed shard accessor
type) are gone, and the two helpers that aliased their own parameter
take tenant_state directly.
Rename the one remaining bare tenant reference to tenant_state, rename
the promotion retry noop test to what it actually exercises, fix a
stale re-Pin comment, and drop the tautological re-route test that
only asserted freshly created objects are reachable.
LeaseForTest moves to TenantCatalog and becomes private on GroupIndex
(friended), so test introspection goes through the catalog boundary
instead of reaching into its group index member.
GroupIndex, ObjectIndex, quota_account and disk_object_count become
private; external code now goes through intention-revealing catalog
methods (GroupMembers, the dynamic-replication lease surface,
BindQuotaAccount/BoundQuotaAccount, AccountRestoredDiskObject, plus the
existing route accessors). Test introspection (LeaseForTest,
HasDynamicReplicationLeaseForKeyForTest, LockRouteForTesting) is
forwarded through the catalog as well, the group-contention benchmark
uses a standalone GroupIndex, and the whitebox test that hand-built a
disconnected route/membership state is dropped — that state is
unconstructible through the publication API by design.

@Aionw Aionw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified on 3f530134. Most of the previous review is fixed — I re-ran the deterministic probes plus the repo suites.

Fixed (probes now pass):

  • UpsertStart, FinalizeRemovedReplicasAfterDurable, BatchRemove claim teardown under the entry lock (R2a/R2b/R2c).
  • Erase() hands its held lock to EraseMetadataLocked; a Get arriving during the teardown handoff is no longer served (R3).
  • The reaper / ClearStaleHandles / snapshot cleanup keep pinned handles instead of re-resolving keys (R1, synchronous paths).
  • Create / AllocateAndInsertMetadata lock the envelope before publishing (R5 empty-envelope race).
  • GroupIndex::AddMember materializes + registers + returns the lease atomically (R6).
  • Repo suites: master_service_test 57/57, master_service_group_test 11/11, catalog tests 20/20.

Still open (probes fail):

  1. The HA/oplog deferred finalizers are still key-addressed and act on whatever entry is current (FinalizeMetadataEraseAfterDurable, FinalizeExpiredProcessingReplicasAfterDurable).
  2. Manual read paths (BatchExistKey, GetReplicaListByRegex, GetReplicaListForAdmin, GetAllKeys) do not check is_torn_down.
  3. Create()'s insert-failure fallback can adopt a torn-down entry, so AddReplica can acknowledge a registration for a key that is already gone.
  4. (doc) The new lock-order invariant comment is contradicted by the entry -> route nesting in Create/AllocateAndInsertMetadata.

Details in the inline comments. Happy to share the probe file if useful.

Comment thread mooncake-store/src/master_service.cpp Outdated
shard->tenants.erase(tenant_it);
}
auto& tenant_state = *tenant_handle;
EraseMetadata(tenant_state, tenant_state.Get(durable_entry.object_key),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Durable metadata-erase finalizer still erases by key, not by entry identity.

This callback runs on the oplog writer's callback thread after durability. ClearStaleHandles queues it against a specific invalidated entry (master_service.cpp:2694), but here the key is resolved again at callback time and whatever entry is current gets erased. If the old entry is gone by then and a new object has been published under the same key (a retry Put after the stale-handle sweep is the natural flow), this silently deletes the replacement.

Reproduced deterministically: with a fresh valid entry under key K, invoking the finalizer with an old REMOVE entry for K erases it (ContainsObject(K) == false).

Fix: carry the entry identity (ObjectEntry::generation() captured at scheduling, or a pinned handle) and revalidate (IsCurrent / generation match) before erasing; only use the key-based fallback when the recorded entry is no longer the published one.

dynamic_lease_id, dynamic_version_epoch,
ttl);
? [this, ttl](const OpLogEntry& durable_entry) {
FinalizeExpiredProcessingReplicasAfterDurable(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Expired-processing finalizer pops whatever entry the key resolves to now.

This schedules FinalizeExpiredProcessingReplicasAfterDurable for the expired entry by key, but the callback re-resolves the key with a fresh accessor and pops all PROCESSING replicas of whatever entry it finds — it never checks that this is the same entry, nor even that its put_start_time matches the TTL that triggered the cleanup. If the old entry was preempted in the meantime (a PutStart after put_start_release_timeout, or an UpsertStart, both erase and re-create the key), the finalizer discards the new object's in-flight replicas and can erase it.

Reproduced deterministically: a fresh PROCESSING entry under the key plus the finalizer for an older entry -> CountReplicas() == 0 and the key is gone.

Fix: pass the entry identity/generation (or a pinned handle) into the callback and skip when the current entry is not the expired one. The REMOVE variant two branches down needs the same treatment.

}
auto entry_lock = entry->LockShared();
const ObjectMetadata& metadata = entry->metadata();
if (!metadata.IsValid() || !HasReadableReplica(metadata)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Torn-down entries are still served by the manual read paths.

The accessors now check !entry->is_torn_down, but these paths open-code the check with metadata.IsValid() only, so they can serve an entry whose teardown already released quota/disk accounting and published the KV remove event — and BatchExistKey refreshes its lease a few lines below:

  • BatchExistKey (master_service.cpp:3125)
  • GetReplicaListByRegex (:4060)
  • GetReplicaListForAdmin (:4243, :4369)
  • GetAllKeys (:3150) lists torn-down keys (minor)

Reproduced deterministically: with is_torn_down set on a published entry, ExistKey/GetReplicaList are correctly rejected, but BatchExistKey returns true and GetReplicaListByRegex still returns the key.

Fix: include !entry->is_torn_down in these manual checks (or route them through the accessors), so every read path agrees that a claimed teardown is not readable.

// re-lock a mutex it already holds.
lock_ = std::unique_lock<std::shared_mutex>();
entry_.reset();
entry_ = tenant_state_->Get(object_id_.user_key);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Create() can adopt a torn-down entry, and AddReplica then acknowledges a registration for a key that is already gone.

Exists() returns false for a torn-down entry, so Create() builds and locks a new envelope. If the route still points at the torn-down entry at that moment, InsertObject fails and this fallback re-resolves the key and adopts whatever it finds — including an entry that has already been claimed for teardown but whose route slot has not been erased yet. AddReplicaForRetainedClient never re-checks Exists() after Create(), so it wires the disk replica into the torn-down entry and returns true; the teardown then erases the route.

Reproduced deterministically with a gate at the teardown's entry-unlock: AddReplica returns success while ContainsObject(key) == false.

Fix: if the fallback entry is torn down, retry the create (the route erase is imminent) or return a retryable error; and re-check accessor.Exists() after Create() before wiring.

Comment thread mooncake-store/include/master_service.h Outdated
* Strict tenant admission and policy mutation paths that need both
* tenant_quota_policy_mutex_ and snapshot_mutex_ must acquire the tenant
* policy mutex first, then snapshot_mutex_.
* The per-object lock is never held together with the object route lock

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The documented lock-order invariant is no longer true.

This (newly added) comment says the per-object lock and the route lock are never held together, but Create() (master_service.h:1678) and AllocateAndInsertMetadata (master_service.cpp:4643) now take entry->LockUnique() before InsertObject, which acquires the route lock — i.e. entry -> route nesting. The call-site comment at :1679 documents the weaker, accurate claim ("no path takes the route lock and then an entry lock"), and I could not find that order violated in the current tree, but the general invariant here and the same wording in EvictGroupOrObject (:1200) should be updated — otherwise future route -> entry code (or the HA route-lock gating tests) will rely on a rule the tree no longer follows.

Comment thread mooncake-store/src/master_service.cpp Outdated
// There is no per-tenant mutex; serialize RemoveAll with an exclusive
// snapshot lock so concurrent RemoveAll calls do not double-count/tear
// down.
std::unique_lock<std::shared_mutex> shared_lock(snapshot_mutex_);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Tenant-scoped RemoveAll now blocks metadata requests across every tenant.

This changes the snapshot barrier from shared to exclusive and holds it for the entire tenant scan and teardown loop. Normal GetReplicaList, ExistKey, PutStart, and PutEnd requests also acquire snapshot_mutex_, so clearing a large tenant A prevents even requests for unrelated tenant B from proceeding until A's cleanup finishes. The baseline held the snapshot barrier shared and serialized mutations through the individual shard locks; this introduces a global pause rather than merely changing the object-lock granularity.

Avoiding duplicate teardown/counting is necessary, but could we keep the snapshot barrier shared and enforce that through the per-entry teardown claim and its return value, or separate appropriately scoped serialization? The global RemoveAll(bool) overload has the same new exclusive-lock pattern and should be considered alongside this one.

A regression test could pause tenant A's cleanup after it enters the scan and verify that a point read/write against tenant B can complete before A is resumed. This would explicitly protect the cross-tenant isolation that the object-level locking change is intended to improve.

- Serve torn-down entries nowhere: gate BatchExistKey, GetAllKeys,
  GetReplicaListByRegex and both batch replica-list paths on the
  teardown flag (GetReplicaListForAdmin is already covered by the
  MetadataAccessorRO::Exists gate).
- PutStart and MetadataAccessorRW::Create no longer adopt a torn-down
  entry; AddReplicaForRetainedClient re-checks the accessor so a
  registration cannot be acknowledged for a key that is logically gone.
- Address deferred finalizers by pinned entry: both re-resolved the key
  at durable time and could act on a same-key recreate;
  FinalizeExpiredProcessingReplicasAfterDurable additionally gates on
  the current index slot.
- Hold the RemoveAll snapshot barrier shared and count erases only on
  a won teardown claim (EraseMetadataLocked now returns bool), so a
  tenant wipe no longer blocks metadata traffic for unrelated tenants.
- Document the real lock order: entry -> route is allowed, route ->
  entry is forbidden.
…ndex

Route lookups hash a string_view directly instead of materializing a
temporary std::string per request: the main table now uses a
transparent hash (StringHash, is_transparent via
std::hash<std::string_view>) with std::equal_to<>, whose equivalence
semantics match the previous std::equal_to<std::string> exactly. The
read-only lookups (Get, IsCurrent, Contains, WithObject) widen to
std::string_view, and TenantCatalog::Get forwards the widened form —
otherwise callers holding a view would still pay the construction at
the catalog boundary. Insert/EraseIf keep their signatures. TenantCatalog's
constructor reserves 4096 route buckets as a pre-sizing hint — rehash
behavior stays the standard-library default with no cap.

Measured (metadata_scan_bench, 100k keys, median of 3): a lookup from
a string_view drops from 687ns to 482ns (-30%, one temporary 17-byte
string construction eliminated); a lookup from an existing std::string
lvalue is unchanged (430ns -> 437ns, within noise).

Tests: object_index_test, tenant_catalog_test, and the full
master_service/scenario/HA/tenant/promotion suite all pass on the
remote build (mooncake_master target builds clean).
Pause a tenant-scoped RemoveAll on one entry's lock and assert that a
point write against another tenant still completes. With the old
exclusive snapshot barrier this deadlocks until the watchdog kills it;
with the shared barrier it passes.
@CAICAIIs
CAICAIIs marked this pull request as ready for review September 11, 2026 09:44
Ports the leased-upsert split (AllocateReplicas + InsertMetadata) to
the tenant-first metadata access path: allocation is shared verbatim,
and InsertMetadata operates on the tenant container instead of the
removed shard accessor.
SnapshotAllocatorManager deep-copied the segment->allocator registry
(names, registration vectors, owner map) on every PutStart, put
commit, and replicate-target allocation. Cache the snapshot
copy-on-write: rebuild only after a registration or owner-map
mutation invalidates it, and hand every reader the same immutable
instance.

@stmatengss stmatengss left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of code changes. Please split the extensive code changes into multiple smaller PRs.

@CAICAIIs

Copy link
Copy Markdown
Contributor Author

Benchmark numbers for the current head vs the pre-tenant-first baseline (ca09c18), measured with master_synthetic_bench (#3147) against a Release-built mooncake_master. Both binaries were built from the same tree and flags on an idle 256-core machine.

Workload: 8 segments x 400 GiB, 448 KiB values (the sglang-derived defaults), group_size=5, fixed arrival, 60 s per run. Medium is the benchmark's default per-segment rate (exist 6.34 / put 3.14 / get 2.02 qps), high is 2x. Two interleaved rounds per side; ~8.7k (medium) and ~17.4k (high) items/s.

load build p50 (ms) p99 (ms) p999 (ms) failed RPCs
medium baseline 0.732 / 0.766 8.106 / 7.911 14.249 / 15.883 0 / 2
medium this PR 0.899 / 0.912 7.054 / 7.284 14.056 / 14.728 0 / 0
high baseline 0.734 / 0.742 8.511 / 8.628 15.430 / 18.180 0 / 3
high this PR 0.877 / 0.805 7.377 / 6.836 15.780 / 15.090 0 / 0

Each cell is round 1 / round 2.

What the numbers show:

  • Tail latency improves: p99 is 10-17% lower and p999 4-8% lower, consistent across rounds and loads.
  • Stability: zero failed RPCs in all PR runs; the baseline had 5 failures total.
  • Median latency is ~14-21% higher, and per-request CPU is ~1.3-1.4x the baseline.

Update (after the upstream/main merge):

Profiling on an idle machine showed the head executes 1.54x the instructions of the baseline with no elevated cache-miss or stall rates, so the gap is straight-line work, not memory stalls. The largest single contributor was SnapshotAllocatorManager() deep-copying the segment->allocator registry on every put/allocate call. It is now a shared copy-on-write snapshot (rebuilt only on mount/unmount) — 58996ff, on top of the upstream merge. Measured effect vs the previously tabulated head, same harness: CPU per request -6% (medium) to -12% (high), p50 -3~4%, p99 ~-4%, across 8/8 interleaved runs. Remaining p50 gap tracks the per-request instruction volume of the new metadata structures and is follow-up work; the tail-latency and stability wins are unchanged.

@rishabhsinha17 rishabhsinha17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the test and DSL lane on 1c1835d, wearing the MasterScenario DSL author hat (#3847, #3927). I read the full diff against merge base 6ae74b3 and ran the affected suites locally at the head, numbers at the end. Aionw's locking findings stand on their own and I did not re-verify them.

The DSL side is in good shape. This PR leaves scenario.h and scenario_contract_test.cpp untouched, so the grammar and its pins hold. Rewriting the ExpireAt engine preserved the verb's contract. Both miss paths keep the old failure text, the rewrite mutates the same two fields, and the new ForTesting setters take the same spinlock the old inline code took. One catalog_.Lookup plus the entry lock keeps the white box surface as narrow as before, and follows the locking rules this PR introduces. Our #3927 scenario suites run unmodified. Flat group all or none eviction matches what EvictsWholeGroupTogether and ActiveGroupMemberBlocksWholeGroup pin, so the model and the scenarios agree.

Most of the test deletions are sound and worth putting on record. ShrinkBucketsIfSparse and its two tests go away with the helper, and the PR body prices the deferred compaction. ReRouteRestoredObjectsMovesStaleShardObjects tested a migration that cannot exist without placement. QueueLimitRejectsCrossShard collapses into QueueLimitRejectsBeyondCap once shards are gone. GroupedRoutingUsesHashOfTenantAndKeyOnly pinned shard hash math, and group_index_test now covers its membership half. And the renamed ClearInvalidHandlesSweepPreservesCoverage keeps the erase all, survive all assertions.

Not every deletion is free, though. Dropping both commit versus clear ordering race tests left the suppression branch they pinned uncovered while it is still live, and the fixture gained AccessorCreateRePinsWinnerEntry with no TEST_F invoking it. Both are cheap to fix, details inline.

On the additions, ConcurrentFirstWritesToDistinctKeysShareReachableTenant pins get or create atomicity, CandidateIndexMirrorsEntryState pins the sparse candidate index against drift, and TenantRemoveAllDoesNotBlockOtherTenants proves the shared barrier claim with a held entry lock gate. object_index_test pins EraseIf identity and generation ordering, the index level form of the removal invariants the concurrent scenarios exercise. Good coverage to have.

Local runs at head 1c1835d, same Ubuntu 22.04 container as my #3927 runs, RelWithDebInfo, gcc 11, USE_ETCD=OFF. The thirteen DSL suites come back green, master_scenario_test 44/44, evict 22/22, concurrent 10/10, offload 25/25, ssd 9/9, base scenario 32/32, group 13/13, placement 12/12, replication 16/16, segment 15/15, task 9/9, tenant 10/10, dfs 6/6. Suites this PR edits pass as well, master_service_test 59/59, group_test 11/11, double_erase 1/1, tenant_quota_test 31/31, promotion_on_hit_test 55/55, and the five new unit suites come out 20 for 20. I did not run the HA or client integration matrix.

A rebase is due again. Current main conflicts in master_service.h, master_service.cpp and master_service_tenant_quota_test.cpp, from #4052 and #3805. Worth folding the #3805 per tenant eviction watermark into TenantCatalog during that rebase, it fits this model naturally.

Two DSL follow-ups are mine to take once this merges, renaming GroupOnDifferentShard since the name encodes the removed sharding model, and a contract test pinning ExpireAt's failure report. Please keep both out of this PR given the split request already standing.

// `cleared` would order it after that commit's `stored` — telling subscribers
// to drop an object that is live. Pause the scan right after the shard the new
// key belongs to, commit there, and the clear must be withheld.
TEST_F(MasterServiceTest, ConcurrentCommitDuringScanSuppressesClear) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting this and TenantScopedRemoveAllSuppressesClearOnRace removes the only coverage of the suppression branch in PublishKvClearedIfEpochUnchanged, and that branch is still live. Both RemoveAll overloads read their epoch baseline before the walk and decide per tenant afterwards. The race stays as reachable as it was under shards. Only the uncontended mirror test remains, and it pins the publish side, which leaves kv_cleared_suppressed_by_epoch_ free to regress silently. Your shard hook is gone, but re-expressing this does not need a new hook on MasterService. Your own gate technique from TenantRemoveAllDoesNotBlockOtherTenants reproduces the race deterministically. Park a forced RemoveAll on a held entry lock, then commit a fresh key into the same tenant while the sweep waits on the gate. That key misses the walk's SnapshotObjects snapshot and its PutEnd bumps the epoch past the baseline. Release the gate, join, and expect suppressed 1, published 0, and the racer still readable. One more copy of that gate covers the tenant scoped overload.

// would bind to an orphan entry not in the route. It must re-resolve the
// existing entry instead. Lives on the fixture (a MasterService friend) so
// it can reach the private MetadataAccessorRW.
void AccessorCreateRePinsWinnerEntry(MasterService& service) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No TEST_F invokes this, so the orphan rebind regression it documents goes unpinned. A one line TEST_F in master_service_test.cpp calling it closes that.

if (update(routed)) {
// All of a tenant's keys live in one tenant container, so a single
// directory lookup finds the object (there is no per-shard probe).
auto tenant_handle = service_->catalog_.Lookup(tenant);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Faithful rewrite from where I sit as the DSL owner. Both miss paths keep the old failure text, the same two fields change, and the setters take the spinlock the old inline code took. Dropping the all shards probe is right, it existed only for placements the flat index cannot produce. I will take two follow-ups on my side once this merges, renaming GroupOnDifferentShard and a contract test pinning ExpireAt's failure report. Nothing for you to do here.

#include <algorithm>
#include <atomic>
#include <chrono>
#include <atomic>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate, <atomic> is already included two lines up.

// the TenantCatalog non-empty. All of a tenant's keys live in a single
// TenantCatalog regardless of shard (there is no per-key shard to share),
// so a simple suffix suffices instead of a shard-index probe.
std::string FindKeyOnSameShard(MasterService& service,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper name and the SAME shard wording here and in the file header describe the old model, while the body is now a plain suffix. Worth renaming to something like MakeKeepaliveKey while you are in this file.

Resolve the conflicts introduced by three upstream Store changes:

- kvcache-ai#3805 (per-tenant eviction watermark): the write path no longer retries
  inline eviction, so ChargeTenantQuota and AllocateAndInsertMetadata drop
  the quota-deficit out-parameter and PutStart/UpsertStart call admit()
  once. EvictTenantsOverWatermark and its eviction-thread tick land on the
  tenant-first catalog unchanged.
- kvcache-ai#4103 (shrink metadata maps after sweeps): the shard-level
  kStaleHandleCleanupBatchSize/shard loop does not apply, but the leak it
  fixes does. ShrinkBucketsIfSparse moves to common/shrink_buckets.h and is
  applied to each tenant's object route after ClearStaleHandles sweeps and
  after BatchEvict, using the evicted_tenants list that was collected but
  never consumed.
- kvcache-ai#4052 (oplog writer metrics): only the metric call in master_service.cpp.

The tenant-first model keeps its deliberate divergence: empty tenants stay
in the directory (an Empty()+Remove() race would strand a just-inserted
object in an unreachable catalog), so the upstream per-sweep tenant erase is
not carried over.
…eams

ClearStaleHandles and BatchEvict now shrink the per-tenant object route, so
the two upstream shrink tests are re-expressed against ObjectIndex instead of
the removed metadata shards (no shard selection: a tenant's keys all live in
one route).

Restore the coverage the shard-era tests took with them: the suppression
branch of PublishKvClearedIfEpochUnchanged had no test that forces it. Pin it
twice -- deterministically through the fixture seams, and end to end by
parking a forced RemoveAll on a held entry lock and committing into the same
tenant inside the window.

Also: give AccessorCreateRePinsWinnerEntry the TEST_F it never had, rename
FindKeyOnSameShard to MakeKeepaliveKey now that shards are gone, drop a
duplicate <atomic> include, and remove two comment blocks left over from the
deleted ReRouteRestoredObjects and the removed group-membership forwarders.
The tenant factory runs synchronously under the directory's write lock today,
so capturing the tenant id by reference is safe but silently depends on that.
Capture by value and document why the factory stays a call-site argument
rather than a catalog member.
@CAICAIIs
CAICAIIs marked this pull request as draft September 15, 2026 04:02
@CAICAIIs

Copy link
Copy Markdown
Contributor Author

Reviewed the test and DSL lane on 1c1835d, wearing the MasterScenario DSL author hat (#3847, #3927). I read the full diff against merge base 6ae74b3 and ran the affected suites locally at the head, numbers at the end. Aionw's locking findings stand on their own and I did not re-verify them.

The DSL side is in good shape. This PR leaves scenario.h and scenario_contract_test.cpp untouched, so the grammar and its pins hold. Rewriting the ExpireAt engine preserved the verb's contract. Both miss paths keep the old failure text, the rewrite mutates the same two fields, and the new ForTesting setters take the same spinlock the old inline code took. One catalog_.Lookup plus the entry lock keeps the white box surface as narrow as before, and follows the locking rules this PR introduces. Our #3927 scenario suites run unmodified. Flat group all or none eviction matches what EvictsWholeGroupTogether and ActiveGroupMemberBlocksWholeGroup pin, so the model and the scenarios agree.

Most of the test deletions are sound and worth putting on record. ShrinkBucketsIfSparse and its two tests go away with the helper, and the PR body prices the deferred compaction. ReRouteRestoredObjectsMovesStaleShardObjects tested a migration that cannot exist without placement. QueueLimitRejectsCrossShard collapses into QueueLimitRejectsBeyondCap once shards are gone. GroupedRoutingUsesHashOfTenantAndKeyOnly pinned shard hash math, and group_index_test now covers its membership half. And the renamed ClearInvalidHandlesSweepPreservesCoverage keeps the erase all, survive all assertions.

Not every deletion is free, though. Dropping both commit versus clear ordering race tests left the suppression branch they pinned uncovered while it is still live, and the fixture gained AccessorCreateRePinsWinnerEntry with no TEST_F invoking it. Both are cheap to fix, details inline.

On the additions, ConcurrentFirstWritesToDistinctKeysShareReachableTenant pins get or create atomicity, CandidateIndexMirrorsEntryState pins the sparse candidate index against drift, and TenantRemoveAllDoesNotBlockOtherTenants proves the shared barrier claim with a held entry lock gate. object_index_test pins EraseIf identity and generation ordering, the index level form of the removal invariants the concurrent scenarios exercise. Good coverage to have.

Local runs at head 1c1835d, same Ubuntu 22.04 container as my #3927 runs, RelWithDebInfo, gcc 11, USE_ETCD=OFF. The thirteen DSL suites come back green, master_scenario_test 44/44, evict 22/22, concurrent 10/10, offload 25/25, ssd 9/9, base scenario 32/32, group 13/13, placement 12/12, replication 16/16, segment 15/15, task 9/9, tenant 10/10, dfs 6/6. Suites this PR edits pass as well, master_service_test 59/59, group_test 11/11, double_erase 1/1, tenant_quota_test 31/31, promotion_on_hit_test 55/55, and the five new unit suites come out 20 for 20. I did not run the HA or client integration matrix.

A rebase is due again. Current main conflicts in master_service.h, master_service.cpp and master_service_tenant_quota_test.cpp, from #4052 and #3805. Worth folding the #3805 per tenant eviction watermark into TenantCatalog during that rebase, it fits this model naturally.

Two DSL follow-ups are mine to take once this merges, renaming GroupOnDifferentShard since the name encodes the removed sharding model, and a contract test pinning ExpireAt's failure report. Please keep both out of this PR given the split request already standing.

thanks your review, and I will split this large PR into multiple smaller ones and take your suggestions into account.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants