From 397ae5a4bcd2be8a07a931ee753bf99bcce6d0ac Mon Sep 17 00:00:00 2001 From: Aoi Date: Fri, 16 Oct 2026 16:15:48 +0800 Subject: [PATCH] feat(store): add the namespace layer over the object route The striped object route keeps each key's lock, record and per-object state, but a tenant's metadata needs more kept in step with every write: the keys with work in flight, so the expiry sweep need not walk every object; the order eviction visits objects in, so it need not take a census of every shard; and a seam for policies such as tenant quotas, so they stop being booked by hand on each mutating path. Add that layer, unwired: - The route gives each slot a few owner words (SlotOwnerState) and two intrusive list hooks, which it stores and never reads, and lets the owner reach them through a write guard or write cursor. - metadata::Namespace owns one tenant's route and observes it: as each write guard is released it lists or unlists the key on a striped in-flight list, refiles it in the eviction index, and passes the release on to the namespace policy. - EvictionIndex is the order eviction visits a namespace's objects in; LeaseEvictionIndex ranks keys by lease deadline in 100 ms buckets and needs no read hook, since a read only ever moves a key later. - NamespacePolicy is the seam a policy plugs into (admit a write, approve growth, replace, tear down, write release, restore, capacity change, maintenance), and StoreControl is what the core offers back. - metadata::NamespaceTable maps tenant ids to namespaces. A namespace is created atomically and never moves or goes away while the service runs, so a resolved reference needs no ownership, and the default namespace resolves through an atomic without the map lock. These replace metadata/tenant.h and metadata/tenant_registry.h, the unwired layer they grew from, and their tests. MasterService does not use any of this yet. Co-authored-by: CAICAIIs <3360776475@qq.com> --- .../include/metadata/eviction_index.h | 90 +++++ .../include/metadata/lease_eviction_index.h | 168 +++++++++ mooncake-store/include/metadata/namespace.h | 192 ++++++++++ .../include/metadata/namespace_policy.h | 136 +++++++ .../include/metadata/namespace_table.h | 109 ++++++ .../include/metadata/object_route.h | 60 ++- mooncake-store/include/metadata/tenant.h | 57 --- .../include/metadata/tenant_registry.h | 110 ------ mooncake-store/tests/CMakeLists.txt | 5 +- mooncake-store/tests/eviction_index_test.cpp | 197 ++++++++++ .../tests/metadata_namespace_table_test.cpp | 232 ++++++++++++ .../tests/metadata_namespace_test.cpp | 347 ++++++++++++++++++ .../tests/metadata_tenant_registry_test.cpp | 303 --------------- mooncake-store/tests/metadata_tenant_test.cpp | 51 --- 14 files changed, 1533 insertions(+), 524 deletions(-) create mode 100644 mooncake-store/include/metadata/eviction_index.h create mode 100644 mooncake-store/include/metadata/lease_eviction_index.h create mode 100644 mooncake-store/include/metadata/namespace.h create mode 100644 mooncake-store/include/metadata/namespace_policy.h create mode 100644 mooncake-store/include/metadata/namespace_table.h delete mode 100644 mooncake-store/include/metadata/tenant.h delete mode 100644 mooncake-store/include/metadata/tenant_registry.h create mode 100644 mooncake-store/tests/eviction_index_test.cpp create mode 100644 mooncake-store/tests/metadata_namespace_table_test.cpp create mode 100644 mooncake-store/tests/metadata_namespace_test.cpp delete mode 100644 mooncake-store/tests/metadata_tenant_registry_test.cpp delete mode 100644 mooncake-store/tests/metadata_tenant_test.cpp diff --git a/mooncake-store/include/metadata/eviction_index.h b/mooncake-store/include/metadata/eviction_index.h new file mode 100644 index 0000000000..36037ca701 --- /dev/null +++ b/mooncake-store/include/metadata/eviction_index.h @@ -0,0 +1,90 @@ +#pragma once + +// EvictionIndex: the order in which eviction visits one namespace's objects. +// The namespace owns one and tells it about every key the route hands out: a +// read as its read guard is released, a write as its write guard is. Eviction +// reads it through a cursor, oldest-ranked key first. +// +// An index may rank keys however it likes (by lease deadline, by access +// frequency, by size) as long as it keeps one rule: the rank a key is filed at +// is never later than the rank it really has. A read may raise a key's real +// rank without telling the index, which then catches up when the cursor's +// StillAt is asked about the key under its write lock: that release files the +// key again at its real rank. So an index only has to watch reads when a read +// can lower a rank, and says so at construction; the route skips the read hook +// for every other index. +// +// Ranks are comparable across the namespaces of one service, which all use +// the same kind of index, so eviction can merge their cursors. +// +// Lock order: whatever lock an index takes is a leaf under a key lock, and +// nothing takes a key lock while holding it. + +#include +#include +#include +#include +#include +#include + +#include "metadata/object_route.h" + +namespace mooncake { +namespace metadata { + +class EvictionIndex { + public: + using Clock = std::chrono::system_clock; + // Lower ranks are evicted first. + using Rank = int64_t; + + // A key as the index filed it. + struct Entry { + std::string key; + Rank rank; + }; + + // One walk over the filed keys, lowest rank first. What it hands out is a + // snapshot: a key may be refiled or gone by the time it is acted on. + class Cursor { + public: + virtual ~Cursor() = default; + // The next filed key; nullopt once the walk is done. + virtual std::optional Next() = 0; + // Whether `entry`, which this cursor handed out, still stands at its + // rank. Asked under the key's write lock, so a key that moved on is + // refiled at its real rank as that lock is let go. + [[nodiscard]] virtual bool StillAt(const route::WriteGuard& guard, + const Entry& entry) const = 0; + // How many keys were filed when the walk began. + [[nodiscard]] virtual size_t Total() const = 0; + }; + + explicit EvictionIndex(bool observes_reads) + : observes_reads_(observes_reads) {} + EvictionIndex(const EvictionIndex&) = delete; + EvictionIndex& operator=(const EvictionIndex&) = delete; + virtual ~EvictionIndex() = default; + + // Called as a read guard on the key is released, with the key's lock held + // shared, possibly by several readers at once; only when the index + // observes reads. It must not touch the slot's owner state, which the + // shared lock does not guard. + virtual void OnRead(const route::ReadGuard& guard) = 0; + // Called as a write guard on the key is released, with the key's lock + // held exclusively: files, refiles or drops the key to match the object + // it holds now. + virtual void OnWrite(const route::WriteGuard& guard) = 0; + // A walk over the keys that may be due by `now`. + [[nodiscard]] virtual std::unique_ptr Scan( + Clock::time_point now) const = 0; + + // Whether OnRead must be called. + [[nodiscard]] bool observes_reads() const { return observes_reads_; } + + private: + const bool observes_reads_; +}; + +} // namespace metadata +} // namespace mooncake diff --git a/mooncake-store/include/metadata/lease_eviction_index.h b/mooncake-store/include/metadata/lease_eviction_index.h new file mode 100644 index 0000000000..5a4f978f67 --- /dev/null +++ b/mooncake-store/include/metadata/lease_eviction_index.h @@ -0,0 +1,168 @@ +#pragma once + +// LeaseEvictionIndex: an EvictionIndex that ranks a key by when its object's +// lease runs out, so eviction visits the objects whose lease ran out first. +// +// A key is filed while it holds an object that is not hard pinned and has a +// completed memory replica. Only writes move a key: a read that extends a +// lease leaves it where it was, so a key is never filed later than its lease +// runs out, only earlier, and the index does not observe reads. A cursor tells +// such a key apart by its real deadline, and releasing its write lock files it +// there, which is the whole of the index's upkeep for reads. +// +// Keys are filed in buckets of kBucketWidth by deadline; a key's rank is its +// bucket. A walk at `now` stops after the bucket `now` falls in, which also +// holds keys whose lease has not run out yet: eviction judges each key's real +// lease anyway, and leaving the bucket out would hold back a lease that ran out +// a moment ago. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "metadata/eviction_index.h" +#include "metadata/object_route.h" +#include "object_metadata.h" + +namespace mooncake { +namespace metadata { + +class LeaseEvictionIndex final : public EvictionIndex { + public: + static constexpr Clock::duration kBucketWidth = + std::chrono::milliseconds(100); + + LeaseEvictionIndex() : EvictionIndex(/*observes_reads=*/false) {} + + // Whether eviction may ever take memory from `metadata`. + [[nodiscard]] static bool Qualifies(const ObjectMetadata& metadata) { + return !metadata.IsHardPinned() && + metadata.HasReplica([](const Replica& replica) { + return replica.is_memory_replica() && replica.is_completed(); + }); + } + + void OnRead(const route::ReadGuard&) override {} + + void OnWrite(const route::WriteGuard& guard) override { + route::SlotOwnerState& owner = guard.owner_state(); + const bool file = guard.has_object() && Qualifies(guard.metadata()); + const Rank rank = file ? RankOf(guard.metadata()) : 0; + if (file == owner.eviction_filed && + (!file || rank == owner.eviction_rank)) { + return; + } + std::lock_guard lock(mutex_); + if (owner.eviction_filed) { + Unfile(guard.slot(), owner.eviction_rank); + } + if (file) { + buckets_[rank].push_back(guard.slot()); + ++size_; + } + owner.eviction_filed = file; + owner.eviction_rank = rank; + } + + [[nodiscard]] std::unique_ptr Scan( + Clock::time_point now) const override { + return std::make_unique(*this, BucketOf(now)); + } + + private: + using SlotList = route::KeySlot::EvictionList; + + // Reads the index one bucket at a time, so it never holds the index lock + // for longer than one bucket takes to copy. + class LeaseCursor final : public Cursor { + public: + LeaseCursor(const LeaseEvictionIndex& index, Rank last) + : index_(index), + last_(last), + total_(index.size_.load(std::memory_order_relaxed)) {} + + std::optional Next() override { + if (pos_ == buffer_.size()) { + buffer_.clear(); + pos_ = 0; + next_ = index_.ReadBucket(next_, last_, buffer_); + } + if (pos_ == buffer_.size()) { + return std::nullopt; + } + return std::move(buffer_[pos_++]); + } + + bool StillAt(const route::WriteGuard& guard, + const Entry& entry) const override { + return guard.has_object() && RankOf(guard.metadata()) <= entry.rank; + } + + size_t Total() const override { return total_; } + + private: + const LeaseEvictionIndex& index_; + // The last bucket the walk reads. + const Rank last_; + const size_t total_; + // The first bucket the walk has not read yet. + Rank next_ = std::numeric_limits::min(); + std::vector buffer_; + size_t pos_ = 0; + }; + + static Rank RankOf(const ObjectMetadata& metadata) { + return BucketOf(metadata.EvictionDeadline()); + } + + static Rank BucketOf(Clock::time_point deadline) { + const auto since_epoch = deadline.time_since_epoch(); + Rank bucket = since_epoch / kBucketWidth; + // Round toward the earlier bucket for a deadline before the epoch. + if (since_epoch < Clock::duration::zero() && + since_epoch % kBucketWidth != Clock::duration::zero()) { + --bucket; + } + return bucket; + } + + // Copies the keys of the first non-empty bucket in [from, last] into + // `out` and returns the bucket after it; returns `last + 1` when there is + // none. + Rank ReadBucket(Rank from, Rank last, std::vector& out) const { + std::lock_guard lock(mutex_); + const auto it = buckets_.lower_bound(from); + if (it == buckets_.end() || it->first > last) { + return last + 1; + } + for (const route::KeySlot& slot : it->second) { + out.push_back({slot.key(), it->first}); + } + return it->first + 1; + } + + void Unfile(route::KeySlot& slot, Rank rank) { + const auto it = buckets_.find(rank); + it->second.erase(it->second.iterator_to(slot)); + if (it->second.empty()) { + buckets_.erase(it); + } + --size_; + } + + mutable std::mutex mutex_; + // A node map, so a bucket's list stays put while others come and go. + std::map buckets_; + std::atomic size_{0}; +}; + +} // namespace metadata +} // namespace mooncake diff --git a/mooncake-store/include/metadata/namespace.h b/mooncake-store/include/metadata/namespace.h new file mode 100644 index 0000000000..96f04914bc --- /dev/null +++ b/mooncake-store/include/metadata/namespace.h @@ -0,0 +1,192 @@ +#pragma once + +// Namespace: one tenant's object route, the list of its objects with work in +// flight, the index eviction reads, and the namespace policy over it, if any. +// Replica-action leases and promotion candidates belong to their own +// subsystems, which hold their own state and validate it against the object the +// route holds before acting. +// +// The route (route::ObjectRoute) owns the keys, their locks, the objects and +// the group index; the namespace keeps the in-flight list and the eviction +// index in step with every write guard the route releases, and reports each +// release to the policy. Read releases go to the eviction index alone, and +// only when it observes reads. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "common/transparent_string_hash.h" +#include "metadata/eviction_index.h" +#include "metadata/lease_eviction_index.h" +#include "metadata/namespace_policy.h" +#include "metadata/object_route.h" +#include "tenant_id.h" +#include "types.h" + +namespace mooncake { +namespace metadata { + +class Namespace final : private route::RouteObserver { + public: + // `policy`, when set, outlives the namespace; `attachment` is what it hung + // on this namespace when it was created. Without an `eviction_index` the + // namespace ranks its objects by lease (LeaseEvictionIndex). + explicit Namespace(TenantId id = {}, NamespacePolicy* policy = nullptr, + std::unique_ptr attachment = nullptr, + std::unique_ptr eviction_index = nullptr) + // The route is built first, so it reads the index before it moves. + : objects(this, eviction_index != nullptr && + eviction_index->observes_reads()), + id_(std::move(id)), + policy_(policy), + attachment_(std::move(attachment)), + eviction_index_(eviction_index != nullptr + ? std::move(eviction_index) + : std::make_unique()) {} + Namespace(const Namespace&) = delete; + Namespace& operator=(const Namespace&) = delete; + + route::ObjectRoute objects; + + const TenantId& id() const { return id_; } + + // The policy's say on the object `guard` holds taking `bytes` more memory, + // asked before the memory is allocated; zero bytes asks only whether the + // namespace admits writes. Always granted without a policy. + [[nodiscard]] tl::expected Grow( + const route::WriteGuard& guard, uint64_t bytes) const { + if (policy_ == nullptr) { + return {}; + } + return policy_->OnGrow(Context(guard), bytes); + } + + // Tell the policy the object `guard` holds is about to be replaced under + // this same lock, or torn down for good; see NamespacePolicy::OnReplace + // and OnTearDown. + void BeforeReplace(const route::WriteGuard& guard) const { + if (policy_ != nullptr) { + policy_->OnReplace(Context(guard)); + } + } + void BeforeTearDown(const route::WriteGuard& guard) const { + if (policy_ != nullptr) { + policy_->OnTearDown(Context(guard)); + } + } + + // True when the namespace holds no object and no group membership. + [[nodiscard]] bool Empty() const { return objects.Empty(); } + + // The order eviction visits this namespace's objects in. + [[nodiscard]] const EvictionIndex& eviction_index() const { + return *eviction_index_; + } + + // --- Work in flight ------------------------------------------------------ + // + // The namespace lists the keys whose object carries work in flight (see + // route::ObjectState::HasWorkInFlight), so a sweep for expired work walks + // those instead of every object. Work only starts or finishes under a write + // guard, and the route reports every write guard to the namespace as it is + // released: a key is listed exactly while it holds an object that carries + // work as of the last write guard on it. A write cursor neither starts nor + // finishes such work. + + // The listed keys, as a snapshot to resolve again: an object can finish, + // be torn down or be replaced once the stripe lock is released. + [[nodiscard]] std::vector InFlightKeys() const { + std::vector keys; + for (const InFlightStripe& stripe : in_flight_) { + std::lock_guard lock(stripe.lock); + for (const route::KeySlot& slot : stripe.slots) { + keys.push_back(slot.key()); + } + } + return keys; + } + + private: + PolicyContext Context(const route::WriteGuard& guard) const { + return PolicyContext{id_, attachment_.get(), guard}; + } + + void OnWriteRelease(const route::WriteGuard& guard) override { + SyncInFlight(guard); + eviction_index_->OnWrite(guard); + if (policy_ != nullptr) { + policy_->OnWriteRelease(Context(guard)); + } + // A key with no object may be dropped once this guard lets go, or + // published again, so whatever is kept on it must be back to its + // initial value by now. + assert(guard.has_object() || IsInitial(guard.owner_state())); + } + + static bool IsInitial(const route::SlotOwnerState& owner) { + return !owner.in_flight_listed && !owner.eviction_filed && + owner.policy_words == decltype(owner.policy_words){}; + } + + void OnReadRelease(const route::ReadGuard& guard) override { + eviction_index_->OnRead(guard); + } + + // Puts the held key on the in-flight list or takes it off, to match + // whether it holds an object that carries work. The stripe lock is taken + // only when that changed. + void SyncInFlight(const route::WriteGuard& guard) { + route::SlotOwnerState& owner = guard.owner_state(); + const bool listed = + guard.has_object() && guard.state().HasWorkInFlight(); + if (listed == owner.in_flight_listed) { + return; + } + InFlightStripe& stripe = InFlightStripeOf(guard.key()); + std::lock_guard lock(stripe.lock); + if (listed) { + stripe.slots.push_back(guard.slot()); + } else { + stripe.slots.erase(stripe.slots.iterator_to(guard.slot())); + } + owner.in_flight_listed = listed; + } + + using InFlightList = route::KeySlot::InFlightList; + + // One stripe of the in-flight list. Starting and finishing a write each + // touch the list, so it is striped by key rather than put under one lock + // for the whole namespace. Only writers take these locks, and only to + // link or unlink, so it needs far fewer stripes than the route. + struct InFlightStripe { + mutable std::mutex lock; + InFlightList slots; + }; + + static constexpr size_t kInFlightStripeCount = 64; + + InFlightStripe& InFlightStripeOf(std::string_view key) { + return in_flight_[TransparentStringHash{}(key) % kInFlightStripeCount]; + } + + const TenantId id_; + NamespacePolicy* const policy_; + const std::unique_ptr attachment_; + + // The keys with work in flight, and the keys eviction may visit. + // Declared after the route so they are destroyed first: the route's slots + // must outlive the hooks the lists hold. + std::array in_flight_; + const std::unique_ptr eviction_index_; +}; + +} // namespace metadata +} // namespace mooncake diff --git a/mooncake-store/include/metadata/namespace_policy.h b/mooncake-store/include/metadata/namespace_policy.h new file mode 100644 index 0000000000..3abade97a5 --- /dev/null +++ b/mooncake-store/include/metadata/namespace_policy.h @@ -0,0 +1,136 @@ +#pragma once + +// NamespacePolicy: the decisions the metadata core leaves to a policy layered +// on top of it. The core routes and locks the objects of each namespace; a +// policy decides whether a namespace admits a write, whether an object may +// grow, what an object's footprint costs and when a namespace must shed memory +// (tenant quotas are the policy it was built for), and keeps its own state. +// +// The core calls each method at a fixed point and under fixed locks, written +// on the method. One that runs under a key's lock may take only leaf locks: it +// must not reach the core again, take master_state_mutex_ or client_mutex_, +// or lock another key. + +#include +#include +#include + +#include + +#include "metadata/object_route.h" +#include "tenant_id.h" +#include "types.h" + +namespace mooncake { + +// What a policy hangs on a namespace when the core creates it. The core keeps +// it with the namespace and hands it back on every object-level call, so the +// policy needs no lookup of its own. +class PolicyAttachment { + public: + virtual ~PolicyAttachment() = default; +}; + +// A write admitted by NamespacePolicy::AdmitWrite, held by the core until the +// request that asked for it ends. +class AdmissionToken { + public: + virtual ~AdmissionToken() = default; +}; + +// What an object-level call sees: the namespace, the policy's attachment to +// it, and the key's write lock. `guard.owner_state().policy_words` are what the +// policy keeps on the key; they survive the objects published under the key +// and must be zero once the key holds none. +struct PolicyContext { + const TenantId& tenant_id; + PolicyAttachment* attachment; + const route::WriteGuard& guard; +}; + +// What the core offers a policy, and what its background pass may ask of it, +// outside any object lock. The policy is handed one when it is built. +class StoreControl { + public: + virtual ~StoreControl() = default; + + // Runs `fn(tenant_id, route)` for every namespace. The route may be walked + // with a cursor; while restoring, a write cursor may also reset each key's + // policy words. + virtual void VisitNamespaces( + const std::function& + fn) = 0; + // Objects the namespace holds right now; zero when it does not exist. + virtual size_t ObjectCount(const TenantId& tenant_id) const = 0; + // The memory every namespace's share is carved from. + virtual uint64_t AllocatableMemoryBytes() const = 0; + + struct EvictionResult { + uint64_t freed_bytes{0}; + uint64_t evicted_objects{0}; + }; + // Evicts memory replicas of `tenant_id`'s objects, oldest lease first, + // until `target_bytes` are freed or nothing more qualifies. + virtual EvictionResult EvictNamespaceMemory(const TenantId& tenant_id, + uint64_t target_bytes) = 0; +}; + +class NamespacePolicy { + public: + virtual ~NamespacePolicy() = default; + + // A namespace is being created; runs once per namespace before any of its + // objects exists, outside every core lock. Racing creators of one + // namespace may each call it, and only one result is kept. + virtual std::unique_ptr OnNamespaceCreated( + const TenantId& tenant_id) { + (void)tenant_id; + return nullptr; + } + + // A request is about to write to `tenant_id`; runs before the core takes + // any lock. The token is held until the request ends, so it may carry a + // lock that orders the write against the policy's own control plane. + virtual tl::expected, ErrorCode> AdmitWrite( + const TenantId& tenant_id) { + (void)tenant_id; + return std::unique_ptr{}; + } + + // The object under `ctx.guard` is about to take `bytes` more memory (a new + // write, a replica copy, a promotion); runs under the key's write lock + // before the core allocates. An error refuses the growth. Zero bytes asks + // only whether the namespace admits writes. + virtual tl::expected OnGrow(const PolicyContext& ctx, + uint64_t bytes) { + (void)ctx; + (void)bytes; + return {}; + } + + // The object under `ctx.guard` is about to give up its replicas and be + // replaced, under this same lock, by a new write of the key (an upsert + // that reallocates). Runs while the old replicas are still attached. + virtual void OnReplace(const PolicyContext& ctx) { (void)ctx; } + + // The object under `ctx.guard` is about to be torn down for good, not + // replaced. Runs before the teardown. + virtual void OnTearDown(const PolicyContext& ctx) { (void)ctx; } + + // A write lock on a key is being released, with the lock still held; the + // key may hold an object or none. Cannot fail. + virtual void OnWriteRelease(const PolicyContext& ctx) { (void)ctx; } + + // Metadata was restored (a standby taking over as primary) and no write + // is in flight yet. + virtual void OnRestored() {} + + // The allocatable memory changed (a segment mounted or went away); runs + // outside every core lock. + virtual void OnCapacityChanged() {} + + // One round of the eviction thread, outside every core lock. + virtual void OnMaintenance() {} +}; + +} // namespace mooncake diff --git a/mooncake-store/include/metadata/namespace_table.h b/mooncake-store/include/metadata/namespace_table.h new file mode 100644 index 0000000000..d5fda13a8b --- /dev/null +++ b/mooncake-store/include/metadata/namespace_table.h @@ -0,0 +1,109 @@ +#pragma once + +// NamespaceTable: tenant id -> the namespace holding that tenant's objects, and +// the lifecycle rules MasterService resolves namespaces through. A namespace is +// created atomically, published fully initialized, and from then on stays +// where it is for as long as the service serves: a reference a caller resolved +// never dangles, so it needs no ownership of its own. +// +// Every object operation resolves its namespace while namespaces are created +// rarely, so the table is one map under a reader-writer lock: a lookup takes +// the lock shared and a create takes it exclusively. The default namespace, +// which every request resolves when multi-tenancy is off, is also kept in an +// atomic once created, so resolving it takes no lock and writes to no shared +// cache line. The table locks only its own map: each namespace synchronizes +// its own containers. +// +// The factory is bound once at construction, so every namespace of one table +// is built the same way and a caller only names the one it wants; it is where +// whatever a policy hangs on a namespace is resolved. It runs before the table +// lock is taken, so it may take other locks without nesting them under this +// one, and a namespace is never reachable before it is fully built. Racing +// creators of one id may each run it, and only one result is published, so it +// must have no effect that cannot be repeated, and it must not re-enter this +// table. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "metadata/namespace.h" +#include "tenant_id.h" + +namespace mooncake { +namespace metadata { + +class NamespaceTable { + public: + // One tenant id in, one initialized namespace out. + using Factory = std::function(const TenantId&)>; + + explicit NamespaceTable(Factory factory) : factory_(std::move(factory)) {} + + // Null when the namespace is absent. + [[nodiscard]] Namespace* Lookup(const TenantId& tenant_id) const { + if (tenant_id.IsDefault()) { + return default_.load(std::memory_order_acquire); + } + std::shared_lock lock(mutex_); + const auto it = namespaces_.find(tenant_id); + return it == namespaces_.end() ? nullptr : it->second.get(); + } + + // Atomic get-or-create: concurrent callers for the same id all observe the + // one namespace the winner published; a loser's build is dropped. The + // lookup covers the common case, so the factory runs and the exclusive + // lock is taken on a miss only. + [[nodiscard]] Namespace& GetOrCreate(const TenantId& tenant_id) { + if (Namespace* ns = Lookup(tenant_id)) { + return *ns; + } + auto candidate = factory_(tenant_id); + std::unique_lock lock(mutex_); + Namespace& ns = + *namespaces_.try_emplace(tenant_id, std::move(candidate)) + .first->second; + if (tenant_id.IsDefault()) { + default_.store(&ns, std::memory_order_release); + } + return ns; + } + + // Walks the namespaces present when the walk starts. They are collected + // under the shared lock and `fn(tenant_id, ns)` runs after it is released, + // so `fn` may take the namespace's own locks and may create namespaces; one + // created during the walk joins the next one. + template + void Visit(Fn&& fn) const { + std::vector> namespaces; + { + std::shared_lock lock(mutex_); + namespaces.reserve(namespaces_.size()); + for (const auto& [tenant_id, ns] : namespaces_) { + namespaces.emplace_back(&tenant_id, ns.get()); + } + } + for (const auto& [tenant_id, ns] : namespaces) { + fn(*tenant_id, *ns); + } + } + + private: + const Factory factory_; + // Shared by lookups and walks, exclusive for the rare creates. + mutable std::shared_mutex mutex_; + // A node map, so a namespace and its id stay put while others are created. + std::unordered_map, TenantIdHash> + namespaces_; + // The default namespace once created: a lookup of it skips the lock. + std::atomic default_{nullptr}; +}; + +} // namespace metadata +} // namespace mooncake diff --git a/mooncake-store/include/metadata/object_route.h b/mooncake-store/include/metadata/object_route.h index ca0a852254..1f504ab81e 100644 --- a/mooncake-store/include/metadata/object_route.h +++ b/mooncake-store/include/metadata/object_route.h @@ -33,6 +33,11 @@ // // Lifetime: guards and cursors point into the route, so its owner keeps it // alive for as long as any of them exists; a route destroyed under one aborts. +// +// Owner state: every slot also carries a few words and two list hooks for the +// route's owner (see SlotOwnerState), which the route stores and never reads. +// The owner keeps them in step with each write through the RouteObserver, so +// they describe the key as of the last write guard released on it. #include #include @@ -51,6 +56,7 @@ #include #include +#include #include #include "common/transparent_string_hash.h" @@ -80,6 +86,20 @@ struct ObjectRef { Generation generation{0}; }; +// What the route's owner keeps on every slot, guarded by the slot lock. The +// route stores it and never reads it; it survives the objects published in the +// slot, so the owner returns it to its initial value once the key holds none, +// before the slot can be dropped. +struct SlotOwnerState { + // Whether the owner's in-flight list holds the slot. + bool in_flight_listed{false}; + // Whether the owner's eviction index files the slot, and at what rank. + bool eviction_filed{false}; + int64_t eviction_rank{0}; + // Two words for the owner's namespace policy. + std::array policy_words{}; +}; + // A slot's lock as a guard or cursor holds it in `kMode`. template using SlotLock = std::conditional_t>; + // One publication. The metadata is built in place, since it can be // neither copied nor moved. struct Record { @@ -134,6 +161,21 @@ class KeySlot { std::atomic state_{0}; mutable std::shared_mutex mutex_; std::optional record_; + SlotOwnerState owner_; + // Hooks into the owner's in-flight list and its eviction index. + ListHook in_flight_hook_; + ListHook eviction_hook_; + + public: + // The owner's lists of slots. Each names its hook here, where the hook is + // accessible, so the slot needs no friend for them. The owner links a slot + // only while it holds an object, so a slot is never dropped linked. + using InFlightList = boost::intrusive::list< + KeySlot, boost::intrusive::member_hook>; + using EvictionList = boost::intrusive::list< + KeySlot, boost::intrusive::member_hook>; }; // Each slot is built in its map node and stays there while the map grows, so a @@ -287,6 +329,15 @@ class Guard { void TearDown() const requires kWrite; + // The owner's per-slot state and the slot its lists link, for the + // route's owner only. + SlotOwnerState& owner_state() const + requires kWrite + { + return slot_->owner_; + } + KeySlot& slot() const { return *slot_; } + private: friend class ObjectRoute; @@ -385,7 +436,8 @@ class ObjectRoute { // a time rather than reading one point in time: an object published or // torn down meanwhile may or may not be seen. A write cursor may change an // object's metadata and state but not publish or tear down, and it does - // not run the observer, so it must not start or finish in-flight work. + // not run the observer, so it must not start or finish in-flight work, + // and the owner hears of nothing else it changes either. // // The cursor holds a stripe lock across the loop body, so the body must // not reach this route or another one (debug builds assert it), nor block @@ -527,6 +579,12 @@ class ObjectRoute::Cursor { ObjectRef ref() const { return {key(), generation()}; } Metadata& metadata() const { return slot_.record_->metadata; } State& state() const { return slot_.record_->state; } + // The owner's per-slot state, for the route's owner only. + SlotOwnerState& owner_state() const + requires kWrite + { + return slot_.owner_; + } private: friend class Cursor; diff --git a/mooncake-store/include/metadata/tenant.h b/mooncake-store/include/metadata/tenant.h deleted file mode 100644 index 75b71fce30..0000000000 --- a/mooncake-store/include/metadata/tenant.h +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -// Tenant: one tenant's object route, which holds its objects, the lock of each -// key and the lifecycle of its groups, plus the quota account it was built -// with. Replica-action leases and promotion candidates belong to their own -// subsystems, which validate what they hold against the publication the route -// holds before acting on it. -// -// Identity is the publication: every object published on the route gets a -// generation, so code that lets go of a key and comes back later names the -// object it judged with a route::ObjectRef and acts only while that -// publication is still there. See route::ObjectRoute for the locking rules. - -#include "metadata/object_route.h" -#include "tenant/quota.h" - -namespace mooncake { -namespace metadata { - -class Tenant { - public: - // `quota_account` is the tenant's account in the quota table, or null when - // quotas are off. The registry's factory resolves it before building the - // tenant, and it never changes afterwards: the quota table keeps one stable - // account per tenant id and a policy recompute updates it in place. - explicit Tenant(TenantQuotaHandle quota_account = nullptr) - : quota_account_(quota_account) {} - - Tenant(const Tenant&) = delete; - Tenant& operator=(const Tenant&) = delete; - - // A guard or cursor taken on the route must not outlive the tenant, so - // whoever takes one keeps its handle to the tenant for as long; a removal - // from the registry drops only the registry's handle. - [[nodiscard]] route::ObjectRoute& Route() { return route_; } - [[nodiscard]] const route::ObjectRoute& Route() const { return route_; } - - // True when the tenant holds no object and no group membership. - [[nodiscard]] bool Empty() const { return route_.Empty(); } - - // --- Quota account ------------------------------------------------------- - - [[nodiscard]] TenantQuotaHandle QuotaAccount() const { - return quota_account_; - } - - private: - // Object key -> the key's slot, its lock and, while it holds an object, - // that object's metadata and state; with the group index beside it. - route::ObjectRoute route_; - - // The tenant's quota account, fixed at construction. - const TenantQuotaHandle quota_account_; -}; - -} // namespace metadata -} // namespace mooncake diff --git a/mooncake-store/include/metadata/tenant_registry.h b/mooncake-store/include/metadata/tenant_registry.h deleted file mode 100644 index 269c61afaa..0000000000 --- a/mooncake-store/include/metadata/tenant_registry.h +++ /dev/null @@ -1,110 +0,0 @@ -#pragma once - -// TenantRegistry: tenant id -> a strong handle to that tenant's metadata, and -// the lifecycle rules MasterService resolves tenants through. A tenant is -// created atomically, published fully initialized, and stays reachable, so an -// in-flight reader never finds its tenant reclaimed. -// -// Every object operation resolves its tenant while tenants are created and -// removed rarely, so the table is one map under a reader-writer lock: a lookup -// takes the lock shared, finds the tenant and copies its handle out, and a -// create or remove takes it exclusively. The handle, not the lock, keeps a -// tenant alive, so a remove cannot invalidate a tenant a reader already -// resolved. The registry locks only its own map: each tenant synchronizes its -// own containers. -// -// The factory is bound once at construction, so every tenant of one registry is -// built the same way and a caller only names the tenant it wants; it is where a -// tenant's quota account is resolved and handed to the tenant. It runs before -// the registry lock is taken, so it may take other locks (the quota table's) -// without nesting them under this one, and a handle never escapes before the -// tenant it names is fully built. Racing creators of one id may each run it, -// and only one result is published, so it must have no effect that cannot be -// repeated, and it must not re-enter this registry. -// -// Alive is not current: `Remove` drops the tenant from the registry while a -// handle a caller already holds stays valid, and a later get-or-create for the -// same id builds a new tenant. A caller that must act on the reachable instance -// resolves it through `Lookup` rather than reusing an old handle. - -#include -#include -#include -#include -#include -#include -#include - -#include "metadata/tenant.h" -#include "tenant_id.h" - -namespace mooncake { -namespace metadata { - -class TenantRegistry { - public: - // One tenant id in, one initialized tenant out. - using TenantFactory = - std::function(const TenantId&)>; - - explicit TenantRegistry(TenantFactory factory) - : factory_(std::move(factory)) {} - - // Null when the tenant is absent. - [[nodiscard]] std::shared_ptr Lookup( - const TenantId& tenant_id) const { - std::shared_lock lock(mutex_); - const auto it = tenants_.find(tenant_id); - return it == tenants_.end() ? nullptr : it->second; - } - - // Atomic get-or-create: concurrent callers for the same tenant all observe - // the one Tenant the winner published; a loser's build is dropped. The - // shared lookup covers the common case, so the factory runs and the - // exclusive lock is taken on a miss only. - [[nodiscard]] std::shared_ptr GetOrCreateTenant( - const TenantId& tenant_id) { - if (auto tenant = Lookup(tenant_id)) { - return tenant; - } - auto candidate = factory_(tenant_id); - std::unique_lock lock(mutex_); - return tenants_.try_emplace(tenant_id, std::move(candidate)) - .first->second; - } - - // Drops the tenant from the registry. A handle a caller already holds stays - // valid, so an in-flight operation keeps working on the tenant it resolved. - void Remove(const TenantId& tenant_id) { - std::unique_lock lock(mutex_); - tenants_.erase(tenant_id); - } - - // Walks the tenants present when the walk starts. Their handles are - // collected under the shared lock and `fn` runs after it is released, so - // `fn` may take the tenant's own locks and may create or remove tenants. A - // change made from `fn` (or from any other thread) shows in the next walk, - // never in this one: a tenant created from the callback joins the next - // walk, and a tenant removed during the walk is still visited. - template - void Visit(Fn&& fn) const { - std::vector>> tenants; - { - std::shared_lock lock(mutex_); - tenants.assign(tenants_.begin(), tenants_.end()); - } - for (const auto& entry : tenants) { - fn(entry.first, entry.second); - } - } - - private: - const TenantFactory factory_; - // Shared by lookups and walks, exclusive for the rare creates and removes. - mutable std::shared_mutex mutex_; - std::unordered_map, TenantIdHash> - tenants_; -}; - -} // namespace metadata -} // namespace mooncake diff --git a/mooncake-store/tests/CMakeLists.txt b/mooncake-store/tests/CMakeLists.txt index 00fb1a2a7a..2fb275863a 100644 --- a/mooncake-store/tests/CMakeLists.txt +++ b/mooncake-store/tests/CMakeLists.txt @@ -225,8 +225,9 @@ add_store_test(promotion_candidate_tracker_test add_store_test(dynamic_replication_lease_table_test dynamic_replication_lease_table_test.cpp) add_store_test(group_index_test group_index_test.cpp) -add_store_test(metadata_tenant_test metadata_tenant_test.cpp) -add_store_test(metadata_tenant_registry_test metadata_tenant_registry_test.cpp) +add_store_test(metadata_namespace_test metadata_namespace_test.cpp) +add_store_test(metadata_namespace_table_test metadata_namespace_table_test.cpp) +add_store_test(eviction_index_test eviction_index_test.cpp) add_store_test(segment_test segment_test.cpp) add_executable(local_ssd_test local_ssd/local_ssd_test.cpp) target_include_directories(local_ssd_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/mooncake-store/tests/eviction_index_test.cpp b/mooncake-store/tests/eviction_index_test.cpp new file mode 100644 index 0000000000..3a277ce05e --- /dev/null +++ b/mooncake-store/tests/eviction_index_test.cpp @@ -0,0 +1,197 @@ +#include "metadata/lease_eviction_index.h" + +#include +#include +#include +#include + +#include + +#include "allocator.h" +#include "metadata/namespace.h" +#include "object_test_helpers.h" + +namespace mooncake { +namespace { + +using Clock = std::chrono::system_clock; +using metadata::EvictionIndex; +using std::chrono::seconds; + +// A namespace whose writes the lease eviction index follows, and an allocator +// that hands out the memory replicas its objects hold. +class LeaseEvictionIndexTest : public ::testing::Test { + protected: + // Publishes `key` holding one memory replica in `status`, with its lease + // ending at `deadline`. + void Publish(const std::string& key, Clock::time_point deadline, + ReplicaStatus status = ReplicaStatus::COMPLETE, + bool hard_pinned = false) { + auto guard = ns_.objects.WriteKey(key); + std::vector replicas; + replicas.emplace_back(allocator_->allocate(128), status); + (void)guard.Publish(UUID{1, 2}, Clock::time_point{}, 128, + std::move(replicas), std::nullopt, hard_pinned, + ObjectDataType::UNKNOWN, std::string(), TenantId(), + key); + SetDeadline(guard.metadata(), deadline); + } + + static void SetDeadline(const ObjectMetadata& metadata, + Clock::time_point deadline) { + SpinLocker locker(&metadata.lock); + metadata.lease_->SetDeadline(deadline); + } + + // Every key a walk at `now` hands out, in order. + std::vector Walk(Clock::time_point now) const { + auto cursor = ns_.eviction_index().Scan(now); + std::vector entries; + while (auto entry = cursor->Next()) { + entries.push_back(std::move(*entry)); + } + return entries; + } + + std::vector DueKeys(Clock::time_point now) const { + std::vector keys; + for (const auto& entry : Walk(now)) { + keys.push_back(entry.key); + } + return keys; + } + + size_t Filed() const { return ns_.eviction_index().Scan(t0_)->Total(); } + + const Clock::time_point t0_{seconds(1000)}; + // Declared before the namespace, so it outlives the replicas it gave out. + std::shared_ptr allocator_ = + std::make_shared("segment", 0x100000000ULL, + 64 * 1024 * 1024, "segment"); + metadata::Namespace ns_; +}; + +TEST_F(LeaseEvictionIndexTest, FilesOnlyObjectsEvictionCanTakeMemoryFrom) { + Publish("complete", t0_); + Publish("processing", t0_, ReplicaStatus::PROCESSING); + Publish("hard_pinned", t0_, ReplicaStatus::COMPLETE, /*hard_pinned=*/true); + { + // No replica at all. + auto guard = ns_.objects.WriteKey("empty"); + (void)test::PublishEnvelope(guard); + } + + EXPECT_EQ(Filed(), 1u); + EXPECT_EQ(DueKeys(t0_ + seconds(1)), std::vector{"complete"}); +} + +TEST_F(LeaseEvictionIndexTest, WalksKeysOldestLeaseFirstUpToNow) { + Publish("late", t0_ + seconds(5)); + Publish("middle", t0_ + seconds(1)); + Publish("early", t0_); + + // A bucket is due once it has begun, so "late" is not yet. + EXPECT_EQ(DueKeys(t0_ + seconds(2)), + (std::vector{"early", "middle"})); + EXPECT_EQ(DueKeys(t0_ + seconds(6)), + (std::vector{"early", "middle", "late"})); +} + +TEST_F(LeaseEvictionIndexTest, KeysInOneBucketShareARank) { + Publish("a", t0_); + Publish("b", t0_); + Publish("c", t0_ + seconds(1)); + + const auto entries = Walk(t0_ + seconds(5)); + ASSERT_EQ(entries.size(), 3u); + EXPECT_EQ(entries[0].rank, entries[1].rank); + EXPECT_EQ(entries[2].key, "c"); + EXPECT_LT(entries[1].rank, entries[2].rank); +} + +TEST_F(LeaseEvictionIndexTest, AReadLeavesTheKeyAndStillAtCatchesIt) { + Publish("k", t0_); + Publish("unmoved", t0_); + + // A read extends the lease without a write lock, so the key stays filed + // at its old deadline. + { + auto guard = ns_.objects.Read("k"); + ASSERT_TRUE(guard.has_value()); + SetDeadline(guard->metadata(), t0_ + seconds(10)); + } + auto cursor = ns_.eviction_index().Scan(t0_ + seconds(1)); + std::vector entries; + while (auto entry = cursor->Next()) { + entries.push_back(std::move(*entry)); + } + ASSERT_EQ(entries.size(), 2u); + + // Under the write lock the cursor sees "k" moved on; releasing that lock + // files it at its real deadline. + for (const auto& entry : entries) { + auto guard = ns_.objects.Write(entry.key); + ASSERT_TRUE(guard.has_value()); + EXPECT_EQ(cursor->StillAt(*guard, entry), entry.key == "unmoved"); + } + EXPECT_EQ(DueKeys(t0_ + seconds(1)), std::vector{"unmoved"}); + EXPECT_EQ(DueKeys(t0_ + seconds(11)), + (std::vector{"unmoved", "k"})); +} + +TEST_F(LeaseEvictionIndexTest, KeyLeavesTheIndexWithItsObjectOrItsLastMemory) { + Publish("torn_down", t0_); + Publish("evicted", t0_); + ASSERT_EQ(Filed(), 2u); + + { + auto guard = ns_.objects.Write("torn_down"); + ASSERT_TRUE(guard.has_value()); + guard->TearDown(); + } + { + auto guard = ns_.objects.Write("evicted"); + ASSERT_TRUE(guard.has_value()); + (void)guard->metadata().PopReplicas(); + } + + EXPECT_EQ(Filed(), 0u); + EXPECT_TRUE(DueKeys(t0_ + seconds(1)).empty()); +} + +// An index that counts what the namespace tells it. +class CountingIndex final : public EvictionIndex { + public: + CountingIndex(bool observes_reads, int* reads, int* writes) + : EvictionIndex(observes_reads), reads_(reads), writes_(writes) {} + + void OnRead(const route::ReadGuard&) override { ++*reads_; } + void OnWrite(const route::WriteGuard&) override { ++*writes_; } + std::unique_ptr Scan(Clock::time_point) const override { + return nullptr; + } + + private: + int* reads_; + int* writes_; +}; + +TEST(EvictionIndexWiringTest, ReadsReachOnlyAnIndexThatObservesThem) { + for (const bool observes_reads : {true, false}) { + int reads = 0; + int writes = 0; + metadata::Namespace ns( + TenantId(), nullptr, nullptr, + std::make_unique(observes_reads, &reads, &writes)); + ASSERT_NE(test::PublishObject(ns.objects, "k"), 0u); + EXPECT_EQ(writes, 1); + + ASSERT_TRUE(ns.objects.Read("k").has_value()); + EXPECT_EQ(reads, observes_reads ? 1 : 0); + ASSERT_TRUE(ns.objects.Write("k").has_value()); + EXPECT_EQ(writes, 2); + } +} + +} // namespace +} // namespace mooncake diff --git a/mooncake-store/tests/metadata_namespace_table_test.cpp b/mooncake-store/tests/metadata_namespace_table_test.cpp new file mode 100644 index 0000000000..97e66d2ce0 --- /dev/null +++ b/mooncake-store/tests/metadata_namespace_table_test.cpp @@ -0,0 +1,232 @@ +#include "metadata/namespace_table.h" +#include "object_test_helpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace mooncake { +namespace metadata { +namespace { + +// Every namespace of one table is built through the factory it was +// constructed with, so its tests can pass a plain one. +std::unique_ptr MakeNamespace(const TenantId&) { + return std::make_unique(); +} + +class NamespaceTableCreationTest : public ::testing::TestWithParam {}; + +TEST_P(NamespaceTableCreationTest, GetOrCreateBuildsOncePerId) { + size_t builds = 0; + NamespaceTable table([&builds](const TenantId&) { + ++builds; + return std::make_unique(); + }); + const TenantId& id = GetParam(); + EXPECT_EQ(table.Lookup(id), nullptr); + + Namespace& created = table.GetOrCreate(id); + EXPECT_EQ(builds, 1u); + EXPECT_EQ(table.Lookup(id), &created); + + // A second call finds the published namespace instead of building another. + EXPECT_EQ(&table.GetOrCreate(id), &created); + EXPECT_EQ(builds, 1u); +} + +TEST_P(NamespaceTableCreationTest, ConcurrentCreationPublishesOneWinner) { + std::atomic builds{0}; + NamespaceTable table([&builds](const TenantId&) { + builds.fetch_add(1, std::memory_order_relaxed); + return std::make_unique(); + }); + const TenantId& id = GetParam(); + + constexpr int kRacers = 16; + std::vector observed(kRacers); + std::atomic ready{0}; + std::atomic start{false}; + std::vector threads; + threads.reserve(kRacers); + for (int i = 0; i < kRacers; ++i) { + threads.emplace_back([&, i] { + ready.fetch_add(1, std::memory_order_acq_rel); + while (!start.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + observed[i] = &table.GetOrCreate(id); + }); + } + while (ready.load(std::memory_order_acquire) < kRacers) { + std::this_thread::yield(); + } + start.store(true, std::memory_order_release); + for (auto& thread : threads) { + thread.join(); + } + + for (int i = 0; i < kRacers; ++i) { + EXPECT_EQ(observed[i], observed[0]) + << "racer " << i << " kept a losing build"; + } + EXPECT_EQ(table.Lookup(id), observed[0]); + // Racers that missed the lookup may each build, but only one build is + // published and every racer holds it. + EXPECT_GE(builds.load(std::memory_order_relaxed), 1u); + EXPECT_LE(builds.load(std::memory_order_relaxed), + static_cast(kRacers)); +} + +// The default namespace resolves without the map lock, a named one through +// it; both must behave the same. +INSTANTIATE_TEST_SUITE_P(DefaultAndNamed, NamespaceTableCreationTest, + ::testing::Values(TenantId::Default(), + TenantId("tenant-a"))); + +TEST(NamespaceTableTest, VisitReachesEveryNamespaceAndCarriesABroadcast) { + NamespaceTable table(MakeNamespace); + std::vector created; + for (const TenantId& id : {TenantId::Default(), TenantId("tenant-a")}) { + created.push_back(&table.GetOrCreate(id)); + } + for (Namespace* ns : created) { + ASSERT_NE(test::PublishObject(ns->objects, "k1", "g1"), 0u); + ASSERT_NE(test::PublishObject(ns->objects, "k2", "g1"), 0u); + // A restored namespace starts without membership. + test::ObjectRouteTestPeer::DropGroupMemberships(ns->objects); + ASSERT_TRUE(ns->objects.GroupMembers("g1").empty()); + } + + // The broadcast a standby restore needs: one walk reaches every + // namespace, the default one included. + std::vector visited; + table.Visit([&](const TenantId& id, Namespace& ns) { + EXPECT_EQ(table.Lookup(id), &ns) << id.value(); + visited.push_back(&ns); + ns.objects.RebuildGroupState(); + }); + + std::sort(visited.begin(), visited.end()); + std::sort(created.begin(), created.end()); + EXPECT_EQ(visited, created); + for (const Namespace* ns : created) { + EXPECT_EQ(ns->objects.GroupMembers("g1").size(), 2u); + } +} + +TEST(NamespaceTableTest, VisitWalksTheNamespacesPresentWhenItStarted) { + NamespaceTable table(MakeNamespace); + (void)table.GetOrCreate(TenantId("tenant-a")); + (void)table.GetOrCreate(TenantId("tenant-b")); + + // Creating a namespace from inside the walk is allowed and must not change + // what that walk sees: it iterates the namespaces present when it started. + std::vector seen; + table.Visit([&](const TenantId& id, Namespace&) { + seen.push_back(id.value()); + (void)table.GetOrCreate(TenantId("tenant-c")); + }); + EXPECT_EQ(seen.size(), 2u); + EXPECT_EQ(std::find(seen.begin(), seen.end(), "tenant-c"), seen.end()); + + // The next walk sees it. + std::vector after; + table.Visit( + [&](const TenantId& id, Namespace&) { after.push_back(id.value()); }); + EXPECT_EQ(after.size(), 3u); + EXPECT_NE(std::find(after.begin(), after.end(), "tenant-c"), after.end()); +} + +TEST(NamespaceTableTest, ConcurrentLookupCreateAndVisitStayConsistent) { + // Every namespace this table builds arrives with one object already + // inserted, so a reader that finds one without it saw the namespace + // published before it had finished being built. + NamespaceTable table([](const TenantId&) { + auto ns = std::make_unique(); + [[maybe_unused]] const route::Generation generation = + test::PublishObject(ns->objects, "k1"); + assert(generation != 0); + return ns; + }); + const std::vector ids = { + TenantId::Default(), TenantId("tenant-a"), TenantId("tenant-b"), + TenantId("tenant-c")}; + constexpr uint64_t kRounds = 20000; + + // Once found, a namespace stays where it was: every thread must keep + // finding the address first published for its id. + std::array, 4> published{}; + std::atomic violations{0}; + const auto check = [&](size_t index, Namespace* ns) { + if (ns == nullptr) { + return; + } + Namespace* expected = nullptr; + if (!published[index].compare_exchange_strong(expected, ns) && + expected != ns) { + violations.fetch_add(1, std::memory_order_relaxed); + } + if (ns->objects.ObjectCount() != 1) { + violations.fetch_add(1, std::memory_order_relaxed); + } + }; + + std::vector threads; + for (int c = 0; c < 2; ++c) { + threads.emplace_back([&, c] { + for (uint64_t i = c; i < kRounds; ++i) { + const size_t index = i % ids.size(); + check(index, &table.GetOrCreate(ids[index])); + } + }); + } + for (int l = 0; l < 4; ++l) { + threads.emplace_back([&, l] { + for (uint64_t i = l; i < kRounds; ++i) { + const size_t index = i % ids.size(); + check(index, table.Lookup(ids[index])); + } + }); + } + // A walker creates namespaces from inside its own callback, which must + // neither deadlock nor disturb the walk it is part of. + threads.emplace_back([&] { + for (uint64_t walk = 0; walk < kRounds / 10; ++walk) { + std::vector seen; + table.Visit([&](const TenantId& id, Namespace& ns) { + const size_t index = + std::find(ids.begin(), ids.end(), id) - ids.begin(); + if (std::find(seen.begin(), seen.end(), id.value()) != + seen.end()) { + violations.fetch_add(1, std::memory_order_relaxed); + } + seen.push_back(id.value()); + check(index, &ns); + (void)table.GetOrCreate(ids[walk % ids.size()]); + }); + } + }); + for (auto& thread : threads) { + thread.join(); + } + + EXPECT_EQ(violations.load(), 0); + for (size_t index = 0; index < ids.size(); ++index) { + EXPECT_EQ(table.Lookup(ids[index]), published[index].load()) + << ids[index].value(); + } +} + +} // namespace +} // namespace metadata +} // namespace mooncake diff --git a/mooncake-store/tests/metadata_namespace_test.cpp b/mooncake-store/tests/metadata_namespace_test.cpp new file mode 100644 index 0000000000..3415530a6e --- /dev/null +++ b/mooncake-store/tests/metadata_namespace_test.cpp @@ -0,0 +1,347 @@ +#include "metadata/namespace.h" +#include "object_test_helpers.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace mooncake { +namespace metadata { +namespace { + +// The route's own behaviour (publication, generations, groups, cursors, slot +// collection) is covered by object_route_test.cpp. These cases cover what the +// namespace adds on top: the in-flight list it keeps from every released write +// guard, and Empty(). + +std::vector SortedInFlightKeys(const Namespace& ns) { + auto keys = ns.InFlightKeys(); + std::sort(keys.begin(), keys.end()); + return keys; +} + +// Starts or finishes a primary write on the object under `key`, under a write +// guard of its own. +void SetProcessing(Namespace& ns, const std::string& key, bool processing) { + auto guard = ns.objects.Write(key); + ASSERT_TRUE(guard.has_value()); + guard->state().is_processing = processing; +} + +TEST(NamespaceTest, InFlightListsOnlyObjectsWithWork) { + Namespace ns; + ASSERT_NE(test::PublishObject(ns.objects, "idle"), 0u); + ASSERT_NE(test::PublishObject(ns.objects, "busy"), 0u); + EXPECT_TRUE(ns.InFlightKeys().empty()); + + { + auto guard = ns.objects.Write("busy"); + ASSERT_TRUE(guard.has_value()); + guard->state().is_processing = true; + // Listed as the guard is released, not before. + EXPECT_TRUE(ns.InFlightKeys().empty()); + } + EXPECT_EQ(SortedInFlightKeys(ns), (std::vector{"busy"})); + + // A write guard on an object with no work lists nothing. + { + auto guard = ns.objects.Write("idle"); + ASSERT_TRUE(guard.has_value()); + } + // Starting work again on a listed object lists it once. + SetProcessing(ns, "busy", true); + EXPECT_EQ(SortedInFlightKeys(ns), (std::vector{"busy"})); + + SetProcessing(ns, "busy", false); + EXPECT_TRUE(ns.InFlightKeys().empty()); +} + +TEST(NamespaceTest, AKeyStaysListedWhileAnyWorkRemains) { + Namespace ns; + ASSERT_NE(test::PublishObject(ns.objects, "k1"), 0u); + + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + guard->state().is_processing = true; + guard->state().offloading_task = + OffloadingTask{1, std::chrono::system_clock::now(), {}}; + } + + // The write finished but the offload has not, so the key stays listed. + SetProcessing(ns, "k1", false); + EXPECT_EQ(SortedInFlightKeys(ns), (std::vector{"k1"})); + + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + guard->state().offloading_task.reset(); + } + EXPECT_TRUE(ns.InFlightKeys().empty()); +} + +TEST(NamespaceTest, PublishingWithWorkListsTheKeyOnRelease) { + Namespace ns; + { + // The write that creates the object starts with work in flight. + auto guard = ns.objects.WriteKey("k1"); + ASSERT_FALSE(guard.has_object()); + (void)test::PublishEnvelope(guard); + guard.state().is_processing = true; + EXPECT_TRUE(ns.InFlightKeys().empty()); + } + EXPECT_EQ(SortedInFlightKeys(ns), (std::vector{"k1"})); + + // A second writer that finds the key taken publishes nothing and leaves + // the listing as it is. + EXPECT_EQ(test::PublishObject(ns.objects, "k1"), 0u); + EXPECT_EQ(SortedInFlightKeys(ns), (std::vector{"k1"})); + + SetProcessing(ns, "k1", false); + EXPECT_TRUE(ns.InFlightKeys().empty()); +} + +TEST(NamespaceTest, AWriterThatPublishesNothingListsNothing) { + Namespace ns; + { + auto guard = ns.objects.WriteKey("k1"); + } + EXPECT_TRUE(ns.InFlightKeys().empty()); + EXPECT_TRUE(ns.Empty()); +} + +TEST(NamespaceTest, TearDownTakesAKeyOffTheInFlightList) { + Namespace ns; + ASSERT_NE(test::PublishObject(ns.objects, "k1"), 0u); + ASSERT_NE(test::PublishObject(ns.objects, "k2"), 0u); + SetProcessing(ns, "k1", true); + SetProcessing(ns, "k2", true); + ASSERT_EQ(SortedInFlightKeys(ns), (std::vector{"k1", "k2"})); + + // The object still carried its write, yet the key leaves the list with + // it. + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + guard->TearDown(); + } + EXPECT_EQ(SortedInFlightKeys(ns), (std::vector{"k2"})); + + SetProcessing(ns, "k2", false); + EXPECT_TRUE(ns.InFlightKeys().empty()); +} + +TEST(NamespaceTest, AReplacementIsListedByItsOwnWork) { + Namespace ns; + ASSERT_NE(test::PublishObject(ns.objects, "k1"), 0u); + SetProcessing(ns, "k1", true); + + // A replacement under one guard starts with empty state, so the key is + // unlisted unless the new publication carries work of its own. + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + guard->TearDown(); + (void)test::PublishEnvelope(*guard); + } + EXPECT_TRUE(ns.InFlightKeys().empty()); + + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + guard->TearDown(); + (void)test::PublishEnvelope(*guard); + guard->state().is_processing = true; + } + EXPECT_EQ(SortedInFlightKeys(ns), (std::vector{"k1"})); + + SetProcessing(ns, "k1", false); +} + +TEST(NamespaceTest, DestroyingANamespaceWithListedKeysIsClean) { + // The list is declared after the route, so it lets go of the slots' hooks + // before the route destroys the slots: none is destroyed still linked. + Namespace ns; + ASSERT_NE(test::PublishObject(ns.objects, "k1"), 0u); + ASSERT_NE(test::PublishObject(ns.objects, "k2", "g1"), 0u); + SetProcessing(ns, "k1", true); + SetProcessing(ns, "k2", true); + ASSERT_EQ(ns.InFlightKeys().size(), 2u); +} + +TEST(NamespaceTest, EmptyTracksObjectsAndGroups) { + Namespace ns; + EXPECT_TRUE(ns.Empty()); + + ASSERT_NE(test::PublishObject(ns.objects, "k1", "g1"), 0u); + EXPECT_FALSE(ns.Empty()); + + // Tearing the object down drops its membership with it. + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + guard->TearDown(); + } + EXPECT_TRUE(ns.Empty()); + EXPECT_TRUE(ns.objects.GroupMembers("g1").empty()); +} + +TEST(NamespaceTest, ConcurrentWritersKeepTheInFlightListExact) { + // Writers on different keys share in-flight stripes; each ends with work + // on its even keys only, and the list must say exactly that. + Namespace ns; + constexpr int kThreads = 8; + constexpr int kKeysPerThread = 64; + constexpr int kRounds = 50; + const auto key_of = [](int thread, int k) { + return "t" + std::to_string(thread) + "-k" + std::to_string(k); + }; + std::vector threads; + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&, t] { + for (int k = 0; k < kKeysPerThread; ++k) { + ASSERT_NE(test::PublishObject(ns.objects, key_of(t, k)), 0u); + } + for (int round = 0; round < kRounds; ++round) { + const bool last = round == kRounds - 1; + for (int k = 0; k < kKeysPerThread; ++k) { + SetProcessing(ns, key_of(t, k), + last ? k % 2 == 0 : round % 2 == 0); + } + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + + std::vector expected; + for (int t = 0; t < kThreads; ++t) { + for (int k = 0; k < kKeysPerThread; k += 2) { + expected.push_back(key_of(t, k)); + } + } + std::sort(expected.begin(), expected.end()); + EXPECT_EQ(SortedInFlightKeys(ns), expected); +} + +// A policy that records what the namespace tells it, keeps a count in the +// key's first policy word while the key holds an object, and refuses growth +// past a limit. +class RecordingPolicy final : public NamespacePolicy { + public: + struct Attachment final : PolicyAttachment { + int id; + explicit Attachment(int value) : id(value) {} + }; + + tl::expected OnGrow(const PolicyContext& ctx, + uint64_t bytes) override { + Record(ctx, "grow"); + if (bytes > kGrowLimit) { + return tl::make_unexpected(ErrorCode::NO_AVAILABLE_HANDLE); + } + return {}; + } + void OnReplace(const PolicyContext& ctx) override { + Record(ctx, "replace"); + } + void OnTearDown(const PolicyContext& ctx) override { + Record(ctx, "teardown"); + } + void OnWriteRelease(const PolicyContext& ctx) override { + Record(ctx, ctx.guard.has_object() ? "release" : "release-empty"); + auto& words = ctx.guard.owner_state().policy_words; + words[0] = ctx.guard.has_object() ? words[0] + 1 : 0; + } + + static constexpr uint64_t kGrowLimit = 1024; + std::vector calls; + const TenantId* last_tenant = nullptr; + PolicyAttachment* last_attachment = nullptr; + + private: + void Record(const PolicyContext& ctx, const char* call) { + calls.push_back(std::string(call) + ":" + ctx.guard.key()); + last_tenant = &ctx.tenant_id; + last_attachment = ctx.attachment; + } +}; + +TEST(NamespacePolicyTest, WritesReachThePolicyWithTheirContext) { + RecordingPolicy policy; + auto attachment = std::make_unique(7); + PolicyAttachment* const attached = attachment.get(); + Namespace ns(TenantId("tenant-a"), &policy, std::move(attachment)); + + ASSERT_NE(test::PublishObject(ns.objects, "k1"), 0u); + EXPECT_EQ(policy.calls, (std::vector{"release:k1"})); + ASSERT_NE(policy.last_tenant, nullptr); + EXPECT_EQ(policy.last_tenant->value(), "tenant-a"); + EXPECT_EQ(policy.last_attachment, attached); + + policy.calls.clear(); + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + EXPECT_TRUE(ns.Grow(*guard, RecordingPolicy::kGrowLimit).has_value()); + EXPECT_EQ(ns.Grow(*guard, RecordingPolicy::kGrowLimit + 1).error(), + ErrorCode::NO_AVAILABLE_HANDLE); + // The policy hears of a write only as its guard is released. + EXPECT_EQ(policy.calls, + (std::vector{"grow:k1", "grow:k1"})); + // The words it keeps survive from one write to the next. + EXPECT_EQ(guard->owner_state().policy_words[0], 1u); + } + EXPECT_EQ(policy.calls.back(), "release:k1"); + + // Reads are not the policy's business. + policy.calls.clear(); + ASSERT_TRUE(ns.objects.Read("k1").has_value()); + EXPECT_TRUE(policy.calls.empty()); +} + +TEST(NamespacePolicyTest, ReplaceAndTearDownAreAnnouncedBeforeTheyHappen) { + RecordingPolicy policy; + Namespace ns(TenantId(), &policy); + ASSERT_NE(test::PublishObject(ns.objects, "k1"), 0u); + policy.calls.clear(); + + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + ns.BeforeReplace(*guard); + guard->TearDown(); + (void)test::PublishEnvelope(*guard); + } + { + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + ns.BeforeTearDown(*guard); + guard->TearDown(); + } + EXPECT_EQ(policy.calls, + (std::vector{"replace:k1", "release:k1", + "teardown:k1", "release-empty:k1"})); + EXPECT_TRUE(ns.Empty()); +} + +TEST(NamespacePolicyTest, WithoutAPolicyEveryGrowthIsGranted) { + Namespace ns; + ASSERT_NE(test::PublishObject(ns.objects, "k1"), 0u); + auto guard = ns.objects.Write("k1"); + ASSERT_TRUE(guard.has_value()); + EXPECT_TRUE(ns.Grow(*guard, UINT64_MAX).has_value()); + ns.BeforeReplace(*guard); + ns.BeforeTearDown(*guard); +} + +} // namespace +} // namespace metadata +} // namespace mooncake diff --git a/mooncake-store/tests/metadata_tenant_registry_test.cpp b/mooncake-store/tests/metadata_tenant_registry_test.cpp deleted file mode 100644 index f4b9e27a73..0000000000 --- a/mooncake-store/tests/metadata_tenant_registry_test.cpp +++ /dev/null @@ -1,303 +0,0 @@ -#include "metadata/tenant_registry.h" -#include "object_test_helpers.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace mooncake { -namespace metadata { -namespace { - -// Every tenant of one registry is built through the factory it was constructed -// with, so its tests can pass a plain one. -std::shared_ptr MakeTenant(const TenantId&) { - return std::make_shared(); -} - -TEST(TenantRegistryTest, GetOrCreateTenantBuildsOncePerTenantId) { - size_t builds = 0; - TenantRegistry registry([&builds](const TenantId&) { - ++builds; - return std::make_shared(); - }); - const TenantId tenant("tenant-a"); - EXPECT_EQ(registry.Lookup(tenant), nullptr); - - auto created = registry.GetOrCreateTenant(tenant); - ASSERT_NE(created, nullptr); - EXPECT_EQ(builds, 1u); - EXPECT_EQ(registry.Lookup(tenant).get(), created.get()); - - // A second call finds the published tenant instead of building another. - auto found = registry.GetOrCreateTenant(tenant); - EXPECT_EQ(found.get(), created.get()); - EXPECT_EQ(builds, 1u); -} - -TEST(TenantRegistryTest, ConcurrentCreationPublishesOneWinningTenant) { - std::atomic builds{0}; - TenantRegistry registry([&builds](const TenantId&) { - builds.fetch_add(1, std::memory_order_relaxed); - return std::make_shared(); - }); - const TenantId tenant("tenant-race"); - - constexpr int kRacers = 16; - std::vector> observed(kRacers); - std::atomic ready{0}; - std::atomic start{false}; - std::vector threads; - threads.reserve(kRacers); - for (int i = 0; i < kRacers; ++i) { - threads.emplace_back([&, i] { - ready.fetch_add(1, std::memory_order_acq_rel); - while (!start.load(std::memory_order_acquire)) { - std::this_thread::yield(); - } - observed[i] = registry.GetOrCreateTenant(tenant); - }); - } - while (ready.load(std::memory_order_acquire) < kRacers) { - std::this_thread::yield(); - } - start.store(true, std::memory_order_release); - for (auto& thread : threads) { - thread.join(); - } - - for (int i = 0; i < kRacers; ++i) { - ASSERT_NE(observed[i], nullptr) << "racer " << i; - EXPECT_EQ(observed[i].get(), observed[0].get()) - << "racer " << i << " kept a losing handle"; - } - EXPECT_EQ(registry.Lookup(tenant).get(), observed[0].get()); - // Racers that missed the lookup may each build, but only one build is - // published and every racer holds it. - EXPECT_GE(builds.load(std::memory_order_relaxed), 1u); - EXPECT_LE(builds.load(std::memory_order_relaxed), - static_cast(kRacers)); -} - -TEST(TenantRegistryTest, RemoveDropsTheTenantButNotTheHandle) { - TenantRegistry registry(MakeTenant); - ASSERT_NE(registry.GetOrCreateTenant(TenantId("tenant-a")), nullptr); - ASSERT_NE(registry.GetOrCreateTenant(TenantId("tenant-b")), nullptr); - - // The removed handle still addresses the tenant it resolved, while the - // registry creates a fresh one for the same id. - auto removed = registry.Lookup(TenantId("tenant-a")); - ASSERT_NE(removed, nullptr); - registry.Remove(TenantId("tenant-a")); - EXPECT_EQ(registry.Lookup(TenantId("tenant-a")), nullptr); - ASSERT_NE(test::PublishObject(removed->Route(), "k1"), 0u); - EXPECT_FALSE(removed->Empty()); - - auto recreated = registry.GetOrCreateTenant(TenantId("tenant-a")); - ASSERT_NE(recreated, nullptr); - EXPECT_NE(recreated.get(), removed.get()); - EXPECT_TRUE(recreated->Empty()); - ASSERT_NE(registry.Lookup(TenantId("tenant-b")), nullptr); -} - -// A guard lives on the tenant's route, so whoever takes one keeps the -// tenant's handle for as long; a removal meanwhile does not end the guard. -TEST(TenantRegistryTest, AGuardOutlivesTheRemovalOfItsTenant) { - TenantRegistry registry(MakeTenant); - auto tenant = registry.GetOrCreateTenant(TenantId("tenant-a")); - ASSERT_NE(tenant, nullptr); - auto guard = tenant->Route().WriteKey("k1"); - - registry.Remove(TenantId("tenant-a")); - EXPECT_EQ(registry.Lookup(TenantId("tenant-a")), nullptr); - ASSERT_NE(test::PublishEnvelope(guard), 0u); - EXPECT_TRUE(guard.has_object()); - EXPECT_EQ(tenant->Route().ObjectCount(), 1u); -} - -TEST(TenantRegistryTest, VisitReachesEveryTenantAndCarriesABroadcast) { - TenantRegistry registry(MakeTenant); - std::vector> tenants; - for (const auto* name : {"tenant-a", "tenant-b"}) { - tenants.push_back(registry.GetOrCreateTenant(TenantId(name))); - } - for (auto& tenant : tenants) { - ASSERT_NE(test::PublishObject(tenant->Route(), "k1", "g1"), 0u); - ASSERT_NE(test::PublishObject(tenant->Route(), "k2", "g1"), 0u); - // A restored tenant starts without membership. - test::ObjectRouteTestPeer::DropGroupMemberships(tenant->Route()); - ASSERT_TRUE(tenant->Route().GroupMembers("g1").empty()); - } - - // The broadcast a snapshot restore needs: one walk reaches every tenant, - // and the callback receives the handle the registry publishes. - size_t visited = 0; - registry.Visit( - [&](const TenantId& tenant_id, const std::shared_ptr& tenant) { - EXPECT_NE(tenant, nullptr) << tenant_id.value(); - ++visited; - tenant->Route().RebuildGroupState(); - }); - - EXPECT_EQ(visited, tenants.size()); - for (const auto& tenant : tenants) { - EXPECT_EQ(tenant->Route().GroupMembers("g1").size(), 2u); - } -} - -TEST(TenantRegistryTest, VisitWalksTheTenantsPresentWhenItStarted) { - TenantRegistry registry(MakeTenant); - ASSERT_NE(registry.GetOrCreateTenant(TenantId("tenant-a")), nullptr); - ASSERT_NE(registry.GetOrCreateTenant(TenantId("tenant-b")), nullptr); - - // Creating and removing tenants from inside the walk is allowed and must - // not change what that walk sees: it iterates the tenants present when it - // started, not the registry's current ones. - std::vector seen; - bool published = false; - registry.Visit( - [&](const TenantId& tenant_id, const std::shared_ptr& tenant) { - seen.push_back(tenant_id.value()); - EXPECT_NE(tenant, nullptr); - if (!published) { - published = true; - EXPECT_NE(registry.GetOrCreateTenant(TenantId("tenant-c")), - nullptr); - registry.Remove(TenantId("tenant-a")); - } - }); - - EXPECT_EQ(seen.size(), 2u); - EXPECT_NE(std::find(seen.begin(), seen.end(), "tenant-a"), seen.end()); - EXPECT_NE(std::find(seen.begin(), seen.end(), "tenant-b"), seen.end()); - EXPECT_EQ(std::find(seen.begin(), seen.end(), "tenant-c"), seen.end()); - - // The next walk sees the changes. - std::vector after; - registry.Visit( - [&](const TenantId& tenant_id, const std::shared_ptr&) { - after.push_back(tenant_id.value()); - }); - ASSERT_EQ(after.size(), 2u); - EXPECT_NE(std::find(after.begin(), after.end(), "tenant-b"), after.end()); - EXPECT_NE(std::find(after.begin(), after.end(), "tenant-c"), after.end()); -} - -TEST(TenantRegistryTest, MixedLookupCreateRemoveAndVisitStayConsistent) { - // Every tenant this registry builds arrives with one object already - // inserted, so a reader that finds a tenant without it saw the tenant - // published before it had finished being built. - TenantRegistry registry([](const TenantId&) { - auto tenant = std::make_shared(); - [[maybe_unused]] const route::Generation published = - test::PublishObject(tenant->Route(), "k1"); - assert(published != 0); - return tenant; - }); - const std::vector ids = { - TenantId("tenant-a"), TenantId("tenant-b"), TenantId("tenant-c"), - TenantId("tenant-d")}; - constexpr int kRemoverRounds = 20000; - // The other threads yield between rounds: std::shared_mutex prefers - // readers on glibc, and readers that never pause starve the removers on a - // machine with few cores. The caps only guard against a hang. - constexpr uint64_t kOtherRounds = 200000; - constexpr uint64_t kWalks = 20000; - - std::atomic removers_done{false}; - std::atomic violations{0}; - std::atomic walks{0}; - const auto violation = [&] { - violations.fetch_add(1, std::memory_order_relaxed); - }; - const auto whole = [](const std::shared_ptr& tenant) { - return tenant != nullptr && tenant->Route().ObjectCount() == 1; - }; - - std::vector threads; - // Removers first, so the others can stop when they are done. - for (int r = 0; r < 2; ++r) { - threads.emplace_back([&, r] { - for (int round = 0; round < kRemoverRounds; ++round) { - registry.Remove(ids[(round + r) % ids.size()]); - } - }); - } - for (int c = 0; c < 2; ++c) { - threads.emplace_back([&, c] { - for (uint64_t i = c; i < kOtherRounds && - !removers_done.load(std::memory_order_relaxed); - ++i) { - std::this_thread::yield(); - if (!whole(registry.GetOrCreateTenant(ids[i % ids.size()]))) { - violation(); - } - } - }); - } - for (int l = 0; l < 4; ++l) { - threads.emplace_back([&, l] { - for (uint64_t i = l; i < kOtherRounds && - !removers_done.load(std::memory_order_relaxed); - ++i) { - std::this_thread::yield(); - const auto tenant = registry.Lookup(ids[i % ids.size()]); - if (tenant != nullptr && !whole(tenant)) { - violation(); - } - } - }); - } - // A walker creates and removes tenants from inside its own callback, which - // must neither deadlock nor disturb the walk it is part of. - threads.emplace_back([&] { - uint64_t calls = 0; - for (uint64_t walk = 0; - walk < kWalks && !removers_done.load(std::memory_order_relaxed); - ++walk) { - std::this_thread::yield(); - std::vector seen; - registry.Visit([&](const TenantId& tenant_id, - const std::shared_ptr& tenant) { - if (std::find(seen.begin(), seen.end(), tenant_id.value()) != - seen.end() || - !whole(tenant)) { - violation(); - } - seen.push_back(tenant_id.value()); - const auto& other = ids[++calls % ids.size()]; - if (calls % 2 == 0) { - (void)registry.GetOrCreateTenant(other); - } else { - registry.Remove(other); - } - }); - walks.fetch_add(1, std::memory_order_relaxed); - } - }); - - threads[0].join(); - threads[1].join(); - removers_done.store(true, std::memory_order_relaxed); - for (size_t t = 2; t < threads.size(); ++t) { - threads[t].join(); - } - - EXPECT_EQ(violations.load(), 0); - EXPECT_GT(walks.load(), 0u); - for (const auto& tenant_id : ids) { - const auto tenant = registry.Lookup(tenant_id); - EXPECT_TRUE(tenant == nullptr || whole(tenant)) << tenant_id.value(); - } -} - -} // namespace -} // namespace metadata -} // namespace mooncake diff --git a/mooncake-store/tests/metadata_tenant_test.cpp b/mooncake-store/tests/metadata_tenant_test.cpp deleted file mode 100644 index fa93550724..0000000000 --- a/mooncake-store/tests/metadata_tenant_test.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include "metadata/tenant.h" -#include "object_test_helpers.h" - -#include - -namespace mooncake { -namespace metadata { -namespace { - -// The route itself is covered by object_route_test; these cover what the -// tenant adds around it. - -TEST(TenantTest, QuotaAccountIsTheOneTheTenantWasBuiltWith) { - EXPECT_EQ(Tenant().QuotaAccount(), nullptr); - - TenantQuotaAccount account; - EXPECT_EQ(Tenant(&account).QuotaAccount(), &account); -} - -TEST(TenantTest, EmptyTracksObjectsAndGroups) { - Tenant tenant; - EXPECT_TRUE(tenant.Empty()); - - ASSERT_NE(test::PublishObject(tenant.Route(), "k1", "g1"), 0u); - EXPECT_FALSE(tenant.Empty()); - - // Tearing the object down drops its membership with it. - { - auto guard = tenant.Route().Write("k1"); - ASSERT_TRUE(guard.has_value()); - guard->TearDown(); - } - EXPECT_TRUE(tenant.Empty()); - EXPECT_TRUE(tenant.Route().GroupMembers("g1").empty()); -} - -TEST(TenantTest, EachTenantRoutesItsOwnKeys) { - Tenant first; - Tenant second; - ASSERT_NE(test::PublishObject(first.Route(), "k1"), 0u); - - // The same key is free on another tenant's route. - EXPECT_FALSE(second.Route().Contains("k1")); - ASSERT_NE(test::PublishObject(second.Route(), "k1"), 0u); - EXPECT_EQ(first.Route().ObjectCount(), 1u); - EXPECT_EQ(second.Route().ObjectCount(), 1u); -} - -} // namespace -} // namespace metadata -} // namespace mooncake