diff --git a/mooncake-conductor/include/conductor/common/types.h b/mooncake-conductor/include/conductor/common/types.h index 6a26f1a2e5..906a12de53 100644 --- a/mooncake-conductor/include/conductor/common/types.h +++ b/mooncake-conductor/include/conductor/common/types.h @@ -33,12 +33,22 @@ inline std::optional ParsePublisherKind(std::string_view value) { struct HashProfileConfig { std::string strategy; std::string algorithm; - std::string root_digest; + std::string python_hash_seed; std::string index_projection; bool operator==(const HashProfileConfig&) const = default; }; +struct ResolvedHashProfile { + std::string strategy; + std::string algorithm; + std::string python_hash_seed; + std::string root_digest; + std::string index_projection; + + bool operator==(const ResolvedHashProfile&) const = default; +}; + struct ServiceConfig { std::string endpoint; // kv publisher endpoint std::string replay_endpoint; // replay publisher endpoint @@ -50,7 +60,7 @@ struct ServiceConfig { int64_t block_size = 0; int dp_rank = 0; std::optional cache_group; - HashProfileConfig hash_profile; + ResolvedHashProfile hash_profile; bool operator==(const ServiceConfig&) const = default; }; diff --git a/mooncake-conductor/include/conductor/prefixindex/hash_strategy.h b/mooncake-conductor/include/conductor/prefixindex/hash_strategy.h index 88747a9d46..fbec63623c 100644 --- a/mooncake-conductor/include/conductor/prefixindex/hash_strategy.h +++ b/mooncake-conductor/include/conductor/prefixindex/hash_strategy.h @@ -32,10 +32,17 @@ class HashStrategy { std::vector* out) const = 0; }; -// Returns an empty string when the profile is supported and well formed. +// Resolves a supported source profile and derives its root digest. Returns an +// empty string on success. +std::string ResolveHashProfile(const common::HashProfileConfig& config, + HashProfile* out); + +// Returns an empty string when the resolved profile is supported, well formed, +// and its root digest matches a fresh derivation from python_hash_seed. std::string ValidateHashProfile(const HashProfile& profile); -// Returns nullptr and sets error when the profile is invalid or unsupported. +// Returns nullptr and sets error when the resolved profile shape is invalid or +// unsupported. This consumes the derived root without hashing the seed again. std::unique_ptr CreateHashStrategy(const HashProfile& profile, std::string* error); diff --git a/mooncake-conductor/include/conductor/prefixindex/types.h b/mooncake-conductor/include/conductor/prefixindex/types.h index 10429058d9..93ea964848 100644 --- a/mooncake-conductor/include/conductor/prefixindex/types.h +++ b/mooncake-conductor/include/conductor/prefixindex/types.h @@ -7,6 +7,8 @@ #include #include +#include "conductor/common/types.h" + namespace conductor { namespace prefixindex { @@ -19,14 +21,7 @@ struct ContextKey { bool operator==(const ContextKey&) const = default; }; -struct HashProfile { - std::string strategy; - std::string algorithm; - std::string root_digest; - std::string index_projection; - - bool operator==(const HashProfile&) const = default; -}; +using HashProfile = common::ResolvedHashProfile; struct ProjectedPrefix { uint64_t value = 0; diff --git a/mooncake-conductor/src/kvevent/config.cpp b/mooncake-conductor/src/kvevent/config.cpp index cd5f8e012c..353bc07990 100644 --- a/mooncake-conductor/src/kvevent/config.cpp +++ b/mooncake-conductor/src/kvevent/config.cpp @@ -7,8 +7,10 @@ #include #include #include +#include #include "conductor/common/utils.h" +#include "conductor/prefixindex/hash_strategy.h" namespace conductor { namespace kvevent { @@ -44,17 +46,50 @@ bool JsonInt64(const Json::Value& value, int64_t* out) { return false; } -common::HashProfileConfig ParseHashProfile(const Json::Value& raw) { - common::HashProfileConfig profile; +bool ParseHashProfile(const Json::Value& raw, + common::ResolvedHashProfile* profile, + std::string* error) { + if (!raw.isMember("hash_profile")) { + *error = "hash_profile is required"; + return false; + } const Json::Value& value = raw["hash_profile"]; if (!value.isObject()) { - return profile; + *error = "hash_profile must be an object"; + return false; + } + + static const std::set kAllowedProfileFields = { + "algorithm", "index_projection", "python_hash_seed", "strategy"}; + for (const std::string& name : value.getMemberNames()) { + if (!kAllowedProfileFields.contains(name)) { + *error = "unsupported hash_profile field: " + name; + return false; + } } - profile.strategy = JsonString(value, "strategy"); - profile.algorithm = JsonString(value, "algorithm"); - profile.root_digest = JsonString(value, "root_digest"); - profile.index_projection = JsonString(value, "index_projection"); - return profile; + + common::HashProfileConfig source; + const auto require_string = [&](const char* field, std::string* out) { + if (!value.isMember(field)) { + *error = std::string(field) + " is required"; + return false; + } + if (!value[field].isString()) { + *error = std::string(field) + " must be a string"; + return false; + } + *out = value[field].asString(); + return true; + }; + if (!require_string("strategy", &source.strategy) || + !require_string("algorithm", &source.algorithm) || + !require_string("python_hash_seed", &source.python_hash_seed) || + !require_string("index_projection", &source.index_projection)) { + return false; + } + + *error = prefixindex::ResolveHashProfile(source, profile); + return error->empty(); } bool ParseCacheGroup(const Json::Value& raw, @@ -174,7 +209,12 @@ std::vector ParseConfig(int* http_server_port) { LOG(ERROR) << "Invalid cache_group instance_id=" << name; continue; } - svc.hash_profile = ParseHashProfile(raw); + std::string profile_error; + if (!ParseHashProfile(raw, &svc.hash_profile, &profile_error)) { + LOG(ERROR) << "Invalid hash_profile stream_key=" << name + << " error=" << profile_error; + continue; + } services.push_back(std::move(svc)); } } diff --git a/mooncake-conductor/src/kvevent/event_handler.cpp b/mooncake-conductor/src/kvevent/event_handler.cpp index fcbc7b0ba4..917a5459fd 100644 --- a/mooncake-conductor/src/kvevent/event_handler.cpp +++ b/mooncake-conductor/src/kvevent/event_handler.cpp @@ -37,10 +37,7 @@ prefixindex::ContextKey ContextFromService( prefixindex::HashProfile ProfileFromService( const common::ServiceConfig& service) { - return {.strategy = service.hash_profile.strategy, - .algorithm = service.hash_profile.algorithm, - .root_digest = service.hash_profile.root_digest, - .index_projection = service.hash_profile.index_projection}; + return service.hash_profile; } prefixindex::EngineOwner EngineOwnerFromService( diff --git a/mooncake-conductor/src/kvevent/event_manager.cpp b/mooncake-conductor/src/kvevent/event_manager.cpp index 8938d41b96..4bcb11c81d 100644 --- a/mooncake-conductor/src/kvevent/event_manager.cpp +++ b/mooncake-conductor/src/kvevent/event_manager.cpp @@ -37,10 +37,7 @@ prefixindex::ContextKey ContextFromService( prefixindex::HashProfile ProfileFromService( const common::ServiceConfig& service) { - return {.strategy = service.hash_profile.strategy, - .algorithm = service.hash_profile.algorithm, - .root_digest = service.hash_profile.root_digest, - .index_projection = service.hash_profile.index_projection}; + return service.hash_profile; } prefixindex::EngineRegistration RegistrationFromService( @@ -194,7 +191,7 @@ bool ParseOptionalCacheGroup(const Json::Value& body, coro_http_response& resp, } bool ParseHashProfileConfig(const Json::Value& body, coro_http_response& resp, - common::HashProfileConfig* profile) { + common::ResolvedHashProfile* profile) { if (!body.isMember("hash_profile")) { HttpJsonError(resp, "missing", "hash_profile is required", "hash_profile"); @@ -207,15 +204,35 @@ bool ParseHashProfileConfig(const Json::Value& body, coro_http_response& resp, return false; } static const std::set kAllowedProfileFields = { - "algorithm", "index_projection", "root_digest", "strategy"}; + "algorithm", "index_projection", "python_hash_seed", "strategy"}; if (!RejectUnknownFields(value, kAllowedProfileFields, resp)) { return false; } - return RequiredString(value, "strategy", resp, &profile->strategy) && - RequiredString(value, "algorithm", resp, &profile->algorithm) && - RequiredString(value, "root_digest", resp, &profile->root_digest) && - RequiredString(value, "index_projection", resp, - &profile->index_projection); + + common::HashProfileConfig source; + if (!RequiredString(value, "strategy", resp, &source.strategy) || + !RequiredString(value, "algorithm", resp, &source.algorithm) || + !RequiredString(value, "python_hash_seed", resp, + &source.python_hash_seed) || + !RequiredString(value, "index_projection", resp, + &source.index_projection)) { + return false; + } + + if (std::string error = prefixindex::ResolveHashProfile(source, profile); + !error.empty()) { + const char* field = "python_hash_seed"; + if (source.strategy != "vllm_v1") { + field = "strategy"; + } else if (source.algorithm != "sha256_cbor") { + field = "algorithm"; + } else if (source.index_projection != "low64_be") { + field = "index_projection"; + } + HttpJsonError(resp, "invalid_value", error, field); + return false; + } + return true; } std::string ValidateServiceConfig(const common::ServiceConfig& service) { @@ -499,6 +516,7 @@ Json::Value ServiceConfigToJson(const common::ServiceConfig& svc) { Json::Value profile(Json::objectValue); profile["strategy"] = svc.hash_profile.strategy; profile["algorithm"] = svc.hash_profile.algorithm; + profile["python_hash_seed"] = svc.hash_profile.python_hash_seed; profile["root_digest"] = svc.hash_profile.root_digest; profile["index_projection"] = svc.hash_profile.index_projection; out["HashProfile"] = profile; @@ -969,6 +987,7 @@ void EventManager::RegisterHttpHandlers() { Json::Value profile(Json::objectValue); profile["strategy"] = view.profile.strategy; profile["algorithm"] = view.profile.algorithm; + profile["python_hash_seed"] = view.profile.python_hash_seed; profile["root_digest"] = view.profile.root_digest; profile["index_projection"] = view.profile.index_projection; c["hash_profile"] = profile; diff --git a/mooncake-conductor/src/prefixindex/hash_strategy.cpp b/mooncake-conductor/src/prefixindex/hash_strategy.cpp index f4c7ee853f..13a4dc95c3 100644 --- a/mooncake-conductor/src/prefixindex/hash_strategy.cpp +++ b/mooncake-conductor/src/prefixindex/hash_strategy.cpp @@ -14,6 +14,7 @@ namespace prefixindex { namespace { constexpr size_t kSha256DigestSize = 32; +constexpr uint64_t kMaxPythonHashSeed = std::numeric_limits::max(); void AppendTypeAndLength(uint8_t major_type, uint64_t value, std::vector* out) { @@ -154,6 +155,74 @@ int LowerHexValue(char value) { return -1; } +std::string ValidateProfileSelectors(std::string_view strategy, + std::string_view algorithm, + std::string_view index_projection) { + if (strategy != "vllm_v1") { + return "unsupported hash strategy: " + std::string(strategy); + } + if (algorithm != "sha256_cbor") { + return "unsupported hash algorithm: " + std::string(algorithm); + } + if (index_projection != "low64_be") { + return "unsupported index projection: " + std::string(index_projection); + } + return ""; +} + +std::string ValidatePythonHashSeed(std::string_view seed) { + if (!IsValidUtf8(seed)) { + return "python_hash_seed must contain valid UTF-8"; + } + if (seed == "random") { + return ""; + } + if (seed.empty()) { + return "python_hash_seed must be \"random\" or ASCII decimal text in " + "0..4294967295"; + } + + uint64_t value = 0; + for (const char character : seed) { + if (character < '0' || character > '9') { + return "python_hash_seed must be \"random\" or ASCII decimal text " + "in 0..4294967295"; + } + const uint64_t digit = static_cast(character - '0'); + if (value > (kMaxPythonHashSeed - digit) / 10) { + return "python_hash_seed must be in range 0..4294967295"; + } + value = value * 10 + digit; + } + return ""; +} + +std::string ValidateRootDigest(std::string_view root_digest) { + if (root_digest.size() != kSha256DigestSize * 2) { + return "root_digest must contain exactly 64 lowercase hex characters"; + } + for (const char value : root_digest) { + if (LowerHexValue(value) < 0) { + return "root_digest must contain exactly 64 lowercase hex " + "characters"; + } + } + return ""; +} + +std::string ValidateResolvedHashProfileShape(const HashProfile& profile) { + if (auto error = ValidateProfileSelectors( + profile.strategy, profile.algorithm, profile.index_projection); + !error.empty()) { + return error; + } + if (auto error = ValidatePythonHashSeed(profile.python_hash_seed); + !error.empty()) { + return error; + } + return ValidateRootDigest(profile.root_digest); +} + std::array DecodeRootDigest( std::string_view root_digest) { std::array result{}; @@ -258,31 +327,64 @@ class VllmV1HashStrategy final : public HashStrategy { } // namespace -std::string ValidateHashProfile(const HashProfile& profile) { - if (profile.strategy != "vllm_v1") { - return "unsupported hash strategy: " + profile.strategy; +std::string ResolveHashProfile(const common::HashProfileConfig& config, + HashProfile* out) { + if (out == nullptr) { + return "resolved hash profile output must not be null"; } - if (profile.algorithm != "sha256_cbor") { - return "unsupported hash algorithm: " + profile.algorithm; + *out = {}; + + if (auto error = ValidateProfileSelectors(config.strategy, config.algorithm, + config.index_projection); + !error.empty()) { + return error; } - if (profile.index_projection != "low64_be") { - return "unsupported index projection: " + profile.index_projection; + if (auto error = ValidatePythonHashSeed(config.python_hash_seed); + !error.empty()) { + return error; } - if (profile.root_digest.size() != kSha256DigestSize * 2) { - return "root_digest must contain exactly 64 lowercase hex characters"; + + std::vector encoded_seed; + AppendText(config.python_hash_seed, &encoded_seed); + std::array root_digest{}; + if (auto error = Sha256(encoded_seed, &root_digest); !error.empty()) { + return error; } - for (const char value : profile.root_digest) { - if (LowerHexValue(value) < 0) { - return "root_digest must contain exactly 64 lowercase hex " - "characters"; - } + + *out = {.strategy = config.strategy, + .algorithm = config.algorithm, + .python_hash_seed = config.python_hash_seed, + .root_digest = DigestToHex(root_digest), + .index_projection = config.index_projection}; + return ""; +} + +std::string ValidateHashProfile(const HashProfile& profile) { + if (auto error = ValidateResolvedHashProfileShape(profile); + !error.empty()) { + return error; + } + + HashProfile expected; + const common::HashProfileConfig source{ + .strategy = profile.strategy, + .algorithm = profile.algorithm, + .python_hash_seed = profile.python_hash_seed, + .index_projection = profile.index_projection, + }; + if (auto error = ResolveHashProfile(source, &expected); !error.empty()) { + return error; + } + if (profile.root_digest != expected.root_digest) { + return "root_digest does not match python_hash_seed and hash selectors"; } return ""; } std::unique_ptr CreateHashStrategy(const HashProfile& profile, std::string* error) { - const std::string validation_error = ValidateHashProfile(profile); + const std::string validation_error = + ValidateResolvedHashProfileShape(profile); if (error != nullptr) { *error = validation_error; } diff --git a/mooncake-conductor/tests/compute_hash_test.cpp b/mooncake-conductor/tests/compute_hash_test.cpp index 3fe236c512..6a8be07016 100644 --- a/mooncake-conductor/tests/compute_hash_test.cpp +++ b/mooncake-conductor/tests/compute_hash_test.cpp @@ -12,34 +12,57 @@ namespace { +using conductor::common::HashProfileConfig; using conductor::prefixindex::ContextKey; using conductor::prefixindex::CreateHashStrategy; using conductor::prefixindex::DigestToHex; using conductor::prefixindex::HashBlock; using conductor::prefixindex::HashProfile; +using conductor::prefixindex::ResolveHashProfile; using conductor::prefixindex::ValidateHashProfile; using conductor_test::LoadJsonFixture; using conductor_test::ParseU64; +constexpr char kSeedZeroRoot[] = + "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e"; +constexpr char kPaddedSeedZeroRoot[] = + "8d912e4e62b3cc377b1d1c7a14ef61dffbdaa0990237035c05401c29414c4172"; + HashProfile ProfileFrom(const Json::Value& value) { HashProfile profile; profile.strategy = value["strategy"].asString(); profile.algorithm = value["algorithm"].asString(); + profile.python_hash_seed = value["python_hash_seed"].asString(); profile.root_digest = value["root_digest"].asString(); profile.index_projection = value["index_projection"].asString(); return profile; } +HashProfileConfig SourceProfile(std::string python_hash_seed = "0") { + return {.strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = std::move(python_hash_seed), + .index_projection = "low64_be"}; +} + HashProfile ValidProfile() { return HashProfile{ .strategy = "vllm_v1", .algorithm = "sha256_cbor", - .root_digest = - "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e", + .python_hash_seed = "0", + .root_digest = kSeedZeroRoot, .index_projection = "low64_be", }; } +HashProfile ResolvedProfile(std::string python_hash_seed) { + HashProfile profile; + const std::string error = ResolveHashProfile( + SourceProfile(std::move(python_hash_seed)), &profile); + EXPECT_TRUE(error.empty()) << error; + return profile; +} + std::vector TokensFrom(const Json::Value& values) { std::vector tokens; tokens.reserve(values.size()); @@ -56,7 +79,97 @@ std::optional SaltFrom(const Json::Value& value) { return value.asString(); } -TEST(HashProfile, AcceptsOnlyTheSupportedProfile) { +TEST(HashProfileResolver, MatchesSeedRootGoldenVectors) { + const Json::Value fixture = LoadJsonFixture("hash_golden_vectors.json"); + const Json::Value& vectors = fixture["seed_root_vectors"]; + ASSERT_TRUE(vectors.isArray()); + ASSERT_GE(vectors.size(), 2u); + + for (const auto& vector : vectors) { + const std::string seed = vector["python_hash_seed"].asString(); + SCOPED_TRACE(seed); + HashProfile resolved; + const std::string error = + ResolveHashProfile(SourceProfile(seed), &resolved); + ASSERT_TRUE(error.empty()) << error; + EXPECT_EQ(resolved.python_hash_seed, seed); + EXPECT_EQ(resolved.root_digest, vector["root_digest"].asString()); + } +} + +TEST(HashProfileResolver, AcceptsSupportedSeedsAndPreservesExactText) { + struct SeedCase { + const char* seed; + const char* root_digest; + }; + const SeedCase cases[] = { + {"0", kSeedZeroRoot}, + {"00", kPaddedSeedZeroRoot}, + {"random", + "78d6ac7e28de859e492449dcea03e3807377d69998c5af819fed33a6df490cad"}, + {"4294967295", + "177f280a5695322a18f16c96a26dc99d9c03f905940103dfe24a9c646fe446a8"}, + }; + + for (const SeedCase& test_case : cases) { + SCOPED_TRACE(test_case.seed); + const HashProfile resolved = ResolvedProfile(test_case.seed); + EXPECT_EQ(resolved.strategy, "vllm_v1"); + EXPECT_EQ(resolved.algorithm, "sha256_cbor"); + EXPECT_EQ(resolved.python_hash_seed, test_case.seed); + EXPECT_EQ(resolved.root_digest, test_case.root_digest); + EXPECT_EQ(resolved.index_projection, "low64_be"); + EXPECT_TRUE(ValidateHashProfile(resolved).empty()); + } + + EXPECT_NE(ResolvedProfile("0"), ResolvedProfile("00")); +} + +TEST(HashProfileResolver, RejectsMalformedSeedTextAndClearsOutput) { + const std::vector invalid = { + "", "+1", "-1", " 0", "0 ", "0\n", + "1.0", "Random", "random ", "4294967296", "not-a-seed", "\xe9\x9b\xb6", + }; + + for (const std::string& seed : invalid) { + SCOPED_TRACE(seed); + HashProfile resolved = ValidProfile(); + EXPECT_FALSE( + ResolveHashProfile(SourceProfile(seed), &resolved).empty()); + EXPECT_EQ(resolved, HashProfile{}); + } + + EXPECT_FALSE(ResolveHashProfile(SourceProfile(), nullptr).empty()); +} + +TEST(HashProfileResolver, RejectsInvalidUtf8AndUnsupportedSelectors) { + for (const std::string seed : + {std::string("\xc0\xaf", 2), std::string("\xed\xa0\x80", 3)}) { + HashProfile resolved; + const std::string error = + ResolveHashProfile(SourceProfile(seed), &resolved); + EXPECT_NE(error.find("valid UTF-8"), std::string::npos); + } + + std::vector unsupported; + auto source = SourceProfile(); + source.strategy = "vllm_v2"; + unsupported.push_back(source); + source = SourceProfile(); + source.algorithm = "sha256"; + unsupported.push_back(source); + source = SourceProfile(); + source.index_projection = "high64_be"; + unsupported.push_back(source); + + for (const HashProfileConfig& candidate : unsupported) { + HashProfile resolved; + EXPECT_FALSE(ResolveHashProfile(candidate, &resolved).empty()); + EXPECT_EQ(resolved, HashProfile{}); + } +} + +TEST(HashProfile, AcceptsOnlyTheSupportedResolvedProfile) { HashProfile profile = ValidProfile(); EXPECT_TRUE(ValidateHashProfile(profile).empty()); @@ -67,7 +180,7 @@ TEST(HashProfile, AcceptsOnlyTheSupportedProfile) { EXPECT_NE(CreateHashStrategy(profile, nullptr), nullptr); } -TEST(HashProfile, RejectsUnsupportedAndMalformedProfiles) { +TEST(HashProfile, RejectsUnsupportedAndMalformedResolvedShapes) { std::vector> cases; HashProfile profile = ValidProfile(); @@ -82,6 +195,10 @@ TEST(HashProfile, RejectsUnsupportedAndMalformedProfiles) { profile.index_projection = "high64_be"; cases.emplace_back("projection", profile); + profile = ValidProfile(); + profile.python_hash_seed.clear(); + cases.emplace_back("empty seed", profile); + profile = ValidProfile(); profile.root_digest.pop_back(); cases.emplace_back("short root", profile); @@ -109,9 +226,22 @@ TEST(HashProfile, RejectsUnsupportedAndMalformedProfiles) { } } +TEST(HashProfile, SemanticValidationRejectsForgedSeedRootPair) { + HashProfile forged = ValidProfile(); + forged.root_digest = kPaddedSeedZeroRoot; + + const std::string validation_error = ValidateHashProfile(forged); + EXPECT_NE(validation_error.find("does not match"), std::string::npos); + + std::string factory_error = "stale error"; + EXPECT_NE(CreateHashStrategy(forged, &factory_error), nullptr); + EXPECT_TRUE(factory_error.empty()); +} + TEST(HashStrategyGolden, MatchesVllmAndCbor2Vectors) { const Json::Value fixture = LoadJsonFixture("hash_golden_vectors.json"); const HashProfile profile = ProfileFrom(fixture["profile"]); + ASSERT_TRUE(ValidateHashProfile(profile).empty()); std::string factory_error; auto strategy = CreateHashStrategy(profile, &factory_error); @@ -240,9 +370,8 @@ TEST(HashStrategy, RejectsInvalidComputeInputsWithoutPartialOutput) { strategy->Compute(context, tokens, std::nullopt, nullptr).empty()); } -TEST(HashStrategy, RegisteredRootDigestChangesTheChain) { - HashProfile alternate_profile = ValidProfile(); - alternate_profile.root_digest = std::string(64, '0'); +TEST(HashStrategy, ResolvedSeedChangesTheChain) { + const HashProfile alternate_profile = ResolvedProfile("00"); std::string factory_error; auto default_strategy = CreateHashStrategy(ValidProfile(), &factory_error); diff --git a/mooncake-conductor/tests/concurrency_test.cpp b/mooncake-conductor/tests/concurrency_test.cpp index c68c190081..ee0c95e8c6 100644 --- a/mooncake-conductor/tests/concurrency_test.cpp +++ b/mooncake-conductor/tests/concurrency_test.cpp @@ -46,6 +46,7 @@ using conductor::prefixindex::HashProfile; using conductor::prefixindex::PrefixCacheTable; using conductor::prefixindex::PrefixCacheTableTestPeer; using conductor::prefixindex::ProjectedPrefix; +using conductor::prefixindex::ResolveHashProfile; using conductor::prefixindex::SharedClear; using conductor::prefixindex::SharedMutation; using conductor::prefixindex::SharedObjectOwner; @@ -60,10 +61,16 @@ constexpr char kRaceRootDigest[] = "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e"; HashProfile RaceProfile() { - return {.strategy = "vllm_v1", - .algorithm = "sha256_cbor", - .root_digest = kRaceRootDigest, - .index_projection = "low64_be"}; + const conductor::common::HashProfileConfig source{ + .strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = "0", + .index_projection = "low64_be", + }; + HashProfile profile; + EXPECT_EQ(ResolveHashProfile(source, &profile), ""); + EXPECT_EQ(profile.root_digest, kRaceRootDigest); + return profile; } ServiceConfig RaceService(const std::string& instance_id, int endpoint_slot, @@ -79,10 +86,7 @@ ServiceConfig RaceService(const std::string& instance_id, int endpoint_slot, svc.block_size = 16; svc.dp_rank = dp_rank; svc.cache_group = 0; - svc.hash_profile = {.strategy = "vllm_v1", - .algorithm = "sha256_cbor", - .root_digest = kRaceRootDigest, - .index_projection = "low64_be"}; + svc.hash_profile = RaceProfile(); return svc; } diff --git a/mooncake-conductor/tests/event_ingest_integration_test.cpp b/mooncake-conductor/tests/event_ingest_integration_test.cpp index 79a6dac493..af6eb3a276 100644 --- a/mooncake-conductor/tests/event_ingest_integration_test.cpp +++ b/mooncake-conductor/tests/event_ingest_integration_test.cpp @@ -24,7 +24,6 @@ namespace { -using conductor::common::HashProfileConfig; using conductor::common::PublisherKind; using conductor::common::ServiceConfig; using conductor::kvevent::EventManager; @@ -37,6 +36,7 @@ using conductor::prefixindex::HashBlock; using conductor::prefixindex::HashProfile; using conductor::prefixindex::PrefixCacheTableTestPeer; using conductor::prefixindex::ProjectedPrefix; +using conductor::prefixindex::ResolveHashProfile; using conductor::zmq::DecodedBatch; using conductor::zmq::DecodeMooncakeEventBatch; using conductor::zmq::DecodeVllmEventBatch; @@ -49,17 +49,16 @@ constexpr std::string_view kRootDigest = "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e"; HashProfile TestProfile() { - return {.strategy = "vllm_v1", - .algorithm = "sha256_cbor", - .root_digest = std::string(kRootDigest), - .index_projection = "low64_be"}; -} - -HashProfileConfig TestProfileConfig() { - return {.strategy = "vllm_v1", - .algorithm = "sha256_cbor", - .root_digest = std::string(kRootDigest), - .index_projection = "low64_be"}; + const conductor::common::HashProfileConfig source{ + .strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = "0", + .index_projection = "low64_be", + }; + HashProfile profile; + EXPECT_EQ(ResolveHashProfile(source, &profile), ""); + EXPECT_EQ(profile.root_digest, kRootDigest); + return profile; } ContextKey ContextFor(const ServiceConfig& service) { @@ -89,7 +88,7 @@ ServiceConfig VllmService(std::string instance_id, std::string endpoint, service.dp_rank = dp_rank; service.block_size = 4; service.cache_group = 0; - service.hash_profile = TestProfileConfig(); + service.hash_profile = TestProfile(); return service; } diff --git a/mooncake-conductor/tests/event_manager_test.cpp b/mooncake-conductor/tests/event_manager_test.cpp index 90974b9d69..5a4714f9a6 100644 --- a/mooncake-conductor/tests/event_manager_test.cpp +++ b/mooncake-conductor/tests/event_manager_test.cpp @@ -40,7 +40,6 @@ uint16_t EventManagerTestPeer::HttpPort(EventManager& manager) { namespace { -using conductor::common::HashProfileConfig; using conductor::common::PublisherKind; using conductor::common::ServiceConfig; using conductor::kvevent::EventManager; @@ -57,6 +56,7 @@ using conductor::prefixindex::HashBlock; using conductor::prefixindex::HashProfile; using conductor::prefixindex::PrefixCacheTable; using conductor::prefixindex::ProjectedPrefix; +using conductor::prefixindex::ResolveHashProfile; using conductor::prefixindex::SharedMutation; using conductor::prefixindex::SharedObjectOwner; using conductor::prefixindex::StorageTier; @@ -77,19 +77,16 @@ constexpr std::string_view kRootDigest = constexpr std::string_view kOtherRootDigest = "0000000000000000000000000000000000000000000000000000000000000000"; -HashProfile TestProfile(std::string root_digest = std::string(kRootDigest)) { - return {.strategy = "vllm_v1", - .algorithm = "sha256_cbor", - .root_digest = std::move(root_digest), - .index_projection = "low64_be"}; -} - -HashProfileConfig TestProfileConfig( - std::string root_digest = std::string(kRootDigest)) { - return {.strategy = "vllm_v1", - .algorithm = "sha256_cbor", - .root_digest = std::move(root_digest), - .index_projection = "low64_be"}; +HashProfile TestProfile(std::string python_hash_seed = "0") { + const conductor::common::HashProfileConfig source{ + .strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = std::move(python_hash_seed), + .index_projection = "low64_be", + }; + HashProfile profile; + EXPECT_EQ(ResolveHashProfile(source, &profile), ""); + return profile; } ContextKey ContextFor(const ServiceConfig& service) { @@ -100,16 +97,12 @@ ContextKey ContextFor(const ServiceConfig& service) { } EngineRegistration RegistrationFor(const ServiceConfig& service) { - return { - .context = ContextFor(service), - .profile = {.strategy = service.hash_profile.strategy, - .algorithm = service.hash_profile.algorithm, - .root_digest = service.hash_profile.root_digest, - .index_projection = service.hash_profile.index_projection}, - .instance_id = service.instance_id, - .dp_rank = service.dp_rank, - .effective_block_size = service.block_size, - .cache_group = service.cache_group}; + return {.context = ContextFor(service), + .profile = service.hash_profile, + .instance_id = service.instance_id, + .dp_rank = service.dp_rank, + .effective_block_size = service.block_size, + .cache_group = service.cache_group}; } ServiceConfig VllmService(const std::string& instance_id = "instance-1", @@ -130,7 +123,7 @@ ServiceConfig VllmService(const std::string& instance_id = "instance-1", service.tenant_id = tenant_id; service.dp_rank = dp_rank; service.block_size = block_size; - service.hash_profile = TestProfileConfig(); + service.hash_profile = TestProfile(); return service; } @@ -269,7 +262,7 @@ Json::Value ServiceJson(const ServiceConfig& service) { Json::Value profile(Json::objectValue); profile["strategy"] = service.hash_profile.strategy; profile["algorithm"] = service.hash_profile.algorithm; - profile["root_digest"] = service.hash_profile.root_digest; + profile["python_hash_seed"] = service.hash_profile.python_hash_seed; profile["index_projection"] = service.hash_profile.index_projection; value["hash_profile"] = profile; return value; @@ -350,6 +343,18 @@ TEST(SubscribeToService, InvalidRegistrationCreatesNoState) { EXPECT_EQ(manager.GetIndexer()->GetGlobalView().context_count, 0); } +TEST(SubscribeToService, ForgedResolvedProfileCreatesNoState) { + EventManager manager({}, 0); + auto service = VllmService(); + service.hash_profile.root_digest = std::string(kOtherRootDigest); + + const auto result = EventManagerTestPeer::Subscribe(manager, service); + EXPECT_FALSE(result.first); + EXPECT_NE(result.second.find("root_digest"), std::string::npos); + EXPECT_EQ(EventManagerTestPeer::SubscriberCount(manager), 0u); + EXPECT_EQ(manager.GetIndexer()->GetGlobalView().context_count, 0); +} + TEST(SubscribeToService, ManagerStoppedCreatesNoState) { EventManager manager({}, 0); manager.Stop(); @@ -635,8 +640,10 @@ TEST_F(QueryHttpTest, RejectsMalformedInputsBeforeLookup) { bad_lora["lora_name"] = false; Json::Value bad_instance = ValidQuery(); bad_instance["instance_id"] = 3; - Json::Value override_profile = ValidQuery(); - override_profile["root_digest"] = std::string(kRootDigest); + Json::Value override_seed = ValidQuery(); + override_seed["python_hash_seed"] = "0"; + Json::Value override_root = ValidQuery(); + override_root["root_digest"] = std::string(kRootDigest); const std::vector cases = { {"malformed JSON", "{not-json", "invalid_json"}, @@ -654,7 +661,8 @@ TEST_F(QueryHttpTest, RejectsMalformedInputsBeforeLookup) { {"bad tenant", JsonDocument(bad_tenant), "invalid_type"}, {"bad lora", JsonDocument(bad_lora), "invalid_type"}, {"bad instance", JsonDocument(bad_instance), "invalid_type"}, - {"profile override", JsonDocument(override_profile), "unknown_field"}, + {"seed override", JsonDocument(override_seed), "unknown_field"}, + {"root override", JsonDocument(override_root), "unknown_field"}, }; const auto before = manager_->GetIndexer()->GetGlobalView(); @@ -732,6 +740,121 @@ class RegistrationHttpTest : public ::testing::Test { uint16_t port_ = 0; }; +TEST_F(RegistrationHttpTest, ResolvesReportsAndRetriesSeedProfile) { + const auto service = VllmService(); + ASSERT_EQ(Register(service).status, 200); + ASSERT_EQ(Register(service).status, 200); + EXPECT_EQ(EventManagerTestPeer::SubscriberCount(*manager_), 1u); + + const HttpResponse services_response = HttpGet(port_, "/services"); + ASSERT_EQ(services_response.status, 200); + const Json::Value services = ParseJsonResponse(services_response); + ASSERT_EQ(services["count"].asInt(), 1); + ASSERT_EQ(services["services"].size(), 1u); + const Json::Value& service_profile = services["services"][0]["HashProfile"]; + EXPECT_EQ(service_profile["strategy"].asString(), "vllm_v1"); + EXPECT_EQ(service_profile["algorithm"].asString(), "sha256_cbor"); + EXPECT_EQ(service_profile["python_hash_seed"].asString(), "0"); + EXPECT_EQ(service_profile["root_digest"].asString(), kRootDigest); + EXPECT_EQ(service_profile["index_projection"].asString(), "low64_be"); + + const HttpResponse view_response = HttpGet(port_, "/global_view"); + ASSERT_EQ(view_response.status, 200); + const Json::Value view = ParseJsonResponse(view_response); + ASSERT_EQ(view["context_count"].asInt(), 1); + ASSERT_EQ(view["contexts"].size(), 1u); + const Json::Value& context_profile = view["contexts"][0]["hash_profile"]; + EXPECT_EQ(context_profile["python_hash_seed"].asString(), "0"); + EXPECT_EQ(context_profile["root_digest"].asString(), kRootDigest); +} + +TEST_F(RegistrationHttpTest, RejectsInvalidSeedProfilesWithoutMutation) { + struct Case { + const char* name; + Json::Value body; + const char* reason; + const char* field; + }; + + const Json::Value valid = ServiceJson(VllmService()); + std::vector cases; + auto add = [&](const char* name, Json::Value body, const char* reason, + const char* field) { + cases.push_back({name, std::move(body), reason, field}); + }; + + Json::Value legacy = valid; + legacy["hash_profile"]["root_digest"] = std::string(kRootDigest); + add("legacy root", std::move(legacy), "unknown_field", "root_digest"); + Json::Value missing = valid; + missing["hash_profile"].removeMember("python_hash_seed"); + add("missing seed", std::move(missing), "missing", "python_hash_seed"); + Json::Value misspelled = valid; + misspelled["hash_profile"].removeMember("python_hash_seed"); + misspelled["hash_profile"]["python_hash_sead"] = "0"; + add("misspelled seed", std::move(misspelled), "unknown_field", + "python_hash_sead"); + Json::Value numeric = valid; + numeric["hash_profile"]["python_hash_seed"] = 0; + add("numeric seed", std::move(numeric), "invalid_type", "python_hash_seed"); + Json::Value null_seed = valid; + null_seed["hash_profile"]["python_hash_seed"] = + Json::Value(Json::nullValue); + add("null seed", std::move(null_seed), "invalid_type", "python_hash_seed"); + Json::Value boolean_seed = valid; + boolean_seed["hash_profile"]["python_hash_seed"] = true; + add("boolean seed", std::move(boolean_seed), "invalid_type", + "python_hash_seed"); + Json::Value array_seed = valid; + array_seed["hash_profile"]["python_hash_seed"] = + Json::Value(Json::arrayValue); + add("array seed", std::move(array_seed), "invalid_type", + "python_hash_seed"); + Json::Value object_seed = valid; + object_seed["hash_profile"]["python_hash_seed"] = + Json::Value(Json::objectValue); + add("object seed", std::move(object_seed), "invalid_type", + "python_hash_seed"); + for (const char* seed : {"", "-1", "+1", " 0", "0 ", "4294967296"}) { + Json::Value malformed = valid; + malformed["hash_profile"]["python_hash_seed"] = seed; + add(seed[0] == '\0' ? "empty seed" : seed, std::move(malformed), + "invalid_value", "python_hash_seed"); + } + + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case.name); + const HttpResponse response = + HttpPostJson(port_, "/register", JsonDocument(test_case.body)); + EXPECT_EQ(response.status, 400); + const Json::Value error = ParseJsonResponse(response); + EXPECT_EQ(error["reason"].asString(), test_case.reason); + EXPECT_EQ(error["field"].asString(), test_case.field); + EXPECT_EQ(EventManagerTestPeer::SubscriberCount(*manager_), 0u); + EXPECT_EQ(manager_->GetIndexer()->GetGlobalView().context_count, 0); + } +} + +TEST_F(RegistrationHttpTest, ContextProfileConflictIsAtomic) { + const auto original = VllmService("instance-1"); + ASSERT_EQ(Register(original).status, 200); + + auto conflicting = VllmService("instance-2"); + conflicting.hash_profile = TestProfile("1"); + const HttpResponse response = Register(conflicting); + ASSERT_EQ(response.status, 400); + EXPECT_EQ(ParseJsonResponse(response)["reason"].asString(), + "invalid_registration"); + + EXPECT_EQ(EventManagerTestPeer::SubscriberCount(*manager_), 1u); + const auto view = manager_->GetIndexer()->GetGlobalView(); + ASSERT_EQ(view.contexts.size(), 1u); + EXPECT_EQ(view.contexts[0].profile, TestProfile()); + EXPECT_EQ(view.contexts[0].instance_ranks.size(), 1u); + EXPECT_TRUE(view.contexts[0].instance_ranks.contains("instance-1")); + EXPECT_FALSE(view.contexts[0].instance_ranks.contains("instance-2")); +} + TEST_F(RegistrationHttpTest, PartialUnregisterKeepsInstanceUntilLastRank) { auto rank_zero = VllmService("instance-1", "default", 0); auto rank_one = VllmService("instance-1", "default", 1); @@ -756,7 +879,7 @@ TEST_F(RegistrationHttpTest, RejectsProfileConflictAndMalformedGroup) { ASSERT_EQ(Register(service).status, 200); auto conflict = service; - conflict.hash_profile.root_digest = std::string(kOtherRootDigest); + conflict.hash_profile = TestProfile("1"); EXPECT_EQ(Register(conflict).status, 400); const auto view = manager_->GetIndexer()->GetGlobalView(); ASSERT_EQ(view.contexts.size(), 1u); @@ -777,7 +900,7 @@ TEST_F(RegistrationHttpTest, RejectsProfileConflictAndMalformedGroup) { EXPECT_EQ(manager_->GetIndexer()->GetGlobalView().context_count, 1); invalid = ServiceJson(VllmService("bad-profile")); - invalid["hash_profile"]["root_digest"] = "NOT-A-DIGEST"; + invalid["hash_profile"]["python_hash_seed"] = "not-a-seed"; EXPECT_EQ(HttpPostJson(port_, "/register", JsonDocument(invalid)).status, 400); EXPECT_EQ(manager_->GetIndexer()->GetGlobalView().context_count, 1); @@ -798,7 +921,7 @@ TEST_F(RegistrationHttpTest, RejectsConflictingMooncakeProfileBeforeStart) { ASSERT_EQ(Register(engine).status, 200); auto pool = MooncakeService(engine); - pool.hash_profile.root_digest = std::string(kOtherRootDigest); + pool.hash_profile = TestProfile("1"); EXPECT_EQ(Register(pool).status, 400); EXPECT_EQ(EventManagerTestPeer::SubscriberCount(*manager_), 1u); @@ -935,6 +1058,43 @@ class WarningSink : public google::LogSink { std::vector messages_; }; +TEST(RegistrationLifecycle, + PoolFirstSubscriptionDoesNotReserveProfileAndRejectsLaterMismatch) { + EventManager manager({}, 0); + const auto engine = VllmService("engine", "default", 0, 4); + auto pool = MooncakeService(engine); + pool.hash_profile = TestProfile("1"); + + ASSERT_TRUE(EventManagerTestPeer::Register(manager, pool).first); + EXPECT_EQ(manager.GetIndexer()->GetGlobalView().context_count, 0); + auto handler = EventManagerTestPeer::HandlerFor( + manager, + MakeServiceKey(pool.instance_id, pool.tenant_id, pool.dp_rank)); + ASSERT_NE(handler, nullptr); + + ASSERT_TRUE(EventManagerTestPeer::Register(manager, engine).first); + const auto tokens = Sequence(1, 4); + const auto prefix = + ProjectedFor(ContextFor(engine), TestProfile(), tokens).front(); + WarningSink warnings; + EXPECT_TRUE(DispatchMooncake( + *handler, pool, + {MooncakeStored(engine, prefix.value, "mismatched-object")}) + .empty()); + + EXPECT_TRUE(warnings.Contains("Mooncake profile binding rejected")); + EXPECT_EQ(KVEventHandlerTestPeer::BindingCount(*handler), 0u); + const auto result = + manager.GetIndexer()->Query(ContextFor(engine), tokens).at("engine"); + EXPECT_EQ(result.cpu, 0); + EXPECT_EQ(result.disk, 0); + const auto view = manager.GetIndexer()->GetGlobalView(); + ASSERT_EQ(view.contexts.size(), 1u); + EXPECT_EQ(view.contexts[0].profile, TestProfile()); + EXPECT_EQ(view.contexts[0].prefix_count, 0u); + EXPECT_EQ(EventManagerTestPeer::SubscriberCount(manager), 2u); +} + TEST(KVEventHandlerTest, VllmBatchDpConflictRejectsEveryEvent) { EventManager manager({}, 0); const auto service = VllmService("engine", "default", 2, 4); @@ -1582,7 +1742,7 @@ TEST(ParseConfig, LoadsProfilesAndSkipsInvalidEntries) { "hash_profile": { "strategy": "vllm_v1", "algorithm": "sha256_cbor", - "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e", + "python_hash_seed": "0", "index_projection": "low64_be"}}, "inst-moon": {"endpoint": "tcp://127.0.0.1:6557", "type": "Mooncake", "modelname": "m1", @@ -1590,7 +1750,7 @@ TEST(ParseConfig, LoadsProfilesAndSkipsInvalidEntries) { "hash_profile": { "strategy": "vllm_v1", "algorithm": "sha256_cbor", - "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e", + "python_hash_seed": "0", "index_projection": "low64_be"}}, "bad-group": {"endpoint": "tcp://127.0.0.1:7557", "type": "vLLM", "modelname": "m1", @@ -1624,7 +1784,66 @@ TEST(ParseConfig, LoadsProfilesAndSkipsInvalidEntries) { EXPECT_EQ(services[1].tenant_id, "default"); EXPECT_EQ(services[1].dp_rank, 2); EXPECT_EQ(services[1].cache_group, std::optional(0)); - EXPECT_EQ(services[1].hash_profile, TestProfileConfig()); + EXPECT_EQ(services[1].hash_profile, TestProfile()); + std::remove(path.c_str()); +} + +TEST(ParseConfig, RejectsLegacyMissingMalformedAndUnknownProfiles) { + ConfigEnvGuard guard; + const std::string path = + ::testing::TempDir() + "conductor_cfg_strict_profile.json"; + { + std::ofstream out(path); + out << R"({ + "kvevent_instance": { + "valid": { + "endpoint": "tcp://127.0.0.1:5551", "type": "vLLM", + "modelname": "m1", "block_size": 16, "dp_rank": 0, + "hash_profile": { + "strategy": "vllm_v1", "algorithm": "sha256_cbor", + "python_hash_seed": "00", "index_projection": "low64_be"}}, + "legacy-root": { + "endpoint": "tcp://127.0.0.1:5552", "type": "vLLM", + "modelname": "m1", "block_size": 16, "dp_rank": 0, + "hash_profile": { + "strategy": "vllm_v1", "algorithm": "sha256_cbor", + "python_hash_seed": "0", "index_projection": "low64_be", + "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e"}}, + "missing-seed": { + "endpoint": "tcp://127.0.0.1:5553", "type": "vLLM", + "modelname": "m1", "block_size": 16, "dp_rank": 0, + "hash_profile": { + "strategy": "vllm_v1", "algorithm": "sha256_cbor", + "index_projection": "low64_be"}}, + "malformed-seed": { + "endpoint": "tcp://127.0.0.1:5554", "type": "vLLM", + "modelname": "m1", "block_size": 16, "dp_rank": 0, + "hash_profile": { + "strategy": "vllm_v1", "algorithm": "sha256_cbor", + "python_hash_seed": " 0", "index_projection": "low64_be"}}, + "numeric-seed": { + "endpoint": "tcp://127.0.0.1:5555", "type": "vLLM", + "modelname": "m1", "block_size": 16, "dp_rank": 0, + "hash_profile": { + "strategy": "vllm_v1", "algorithm": "sha256_cbor", + "python_hash_seed": 0, "index_projection": "low64_be"}}, + "misspelled": { + "endpoint": "tcp://127.0.0.1:5556", "type": "vLLM", + "modelname": "m1", "block_size": 16, "dp_rank": 0, + "hash_profile": { + "strategy": "vllm_v1", "algorithm": "sha256_cbor", + "python_hash_sead": "0", "index_projection": "low64_be"}} + } + })"; + } + guard.SetPath(path); + + int port = 13333; + const auto services = conductor::kvevent::ParseConfig(&port); + ASSERT_EQ(services.size(), 1u); + EXPECT_EQ(services[0].instance_id, "valid"); + EXPECT_EQ(services[0].hash_profile, TestProfile("00")); + EXPECT_NE(services[0].hash_profile.root_digest, kRootDigest); std::remove(path.c_str()); } @@ -1642,7 +1861,7 @@ TEST(ParseConfig, ExplicitInstanceIdSupportsMultipleStaticRanks) { "block_size": 16, "dp_rank": 0, "hash_profile": { "strategy": "vllm_v1", "algorithm": "sha256_cbor", - "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e", + "python_hash_seed": "0", "index_projection": "low64_be"}}, "stream-rank-1": { "endpoint": "tcp://127.0.0.1:5558", "type": "vLLM", @@ -1650,7 +1869,7 @@ TEST(ParseConfig, ExplicitInstanceIdSupportsMultipleStaticRanks) { "block_size": 16, "dp_rank": 1, "hash_profile": { "strategy": "vllm_v1", "algorithm": "sha256_cbor", - "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e", + "python_hash_seed": "0", "index_projection": "low64_be"}} } })"; diff --git a/mooncake-conductor/tests/fixtures/hash_golden_vectors.json b/mooncake-conductor/tests/fixtures/hash_golden_vectors.json index 05f83307ec..1acf63937f 100644 --- a/mooncake-conductor/tests/fixtures/hash_golden_vectors.json +++ b/mooncake-conductor/tests/fixtures/hash_golden_vectors.json @@ -1,12 +1,25 @@ { - "description": "Golden vectors for the supported vLLM v1 sha256_cbor block-hash profile.", + "description": "Golden vectors for the supported vLLM v1 sha256_cbor seed-derived root and block-hash profile.", "generator": "vLLM 0.22.0 hash_block_tokens semantics with cbor2 6.1.1 canonical=True", "profile": { "strategy": "vllm_v1", "algorithm": "sha256_cbor", + "python_hash_seed": "0", "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e", "index_projection": "low64_be" }, + "seed_root_vectors": [ + { + "python_hash_seed": "0", + "canonical_cbor_hex": "6130", + "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e" + }, + { + "python_hash_seed": "00", + "canonical_cbor_hex": "623030", + "root_digest": "8d912e4e62b3cc377b1d1c7a14ef61dffbdaa0990237035c05401c29414c4172" + } + ], "cases": [ { "name": "spec_unsalted", diff --git a/mooncake-conductor/tests/model_context_test.cpp b/mooncake-conductor/tests/model_context_test.cpp index 14e235b11c..5ec654e882 100644 --- a/mooncake-conductor/tests/model_context_test.cpp +++ b/mooncake-conductor/tests/model_context_test.cpp @@ -10,6 +10,8 @@ namespace { +using conductor::common::HashProfileConfig; +using conductor::common::ResolvedHashProfile; using conductor::prefixindex::ContextKey; using conductor::prefixindex::EngineRegistration; using conductor::prefixindex::GpuMutation; @@ -26,11 +28,22 @@ concept HasAdditionalSalt = requires(T value) { value.additional_salt; }; template concept HasCacheGroups = requires(T value) { value.cache_groups; }; +template +concept HasPythonHashSeed = requires(T value) { value.python_hash_seed; }; + +template +concept HasRootDigest = requires(T value) { value.root_digest; }; + static_assert(!HasInstanceId); static_assert(!HasCacheSalt); static_assert(!HasAdditionalSalt); +static_assert(!HasPythonHashSeed); static_assert(!HasCacheGroups); static_assert(!HasCacheGroups); +static_assert(HasPythonHashSeed); +static_assert(!HasRootDigest); +static_assert(HasPythonHashSeed); +static_assert(HasRootDigest); static_assert(std::same_as>); static_assert( diff --git a/mooncake-conductor/tests/prefix_indexer_test.cpp b/mooncake-conductor/tests/prefix_indexer_test.cpp index e9a29091e3..6d76eb5916 100644 --- a/mooncake-conductor/tests/prefix_indexer_test.cpp +++ b/mooncake-conductor/tests/prefix_indexer_test.cpp @@ -35,6 +35,8 @@ using conductor::prefixindex::StorageTier; constexpr char kRootDigest[] = "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e"; +constexpr char kPaddedSeedRootDigest[] = + "8d912e4e62b3cc377b1d1c7a14ef61dffbdaa0990237035c05401c29414c4172"; ContextKey TestContext(int64_t block_size = 16) { return {.tenant_id = "tenant-a", @@ -46,10 +48,19 @@ ContextKey TestContext(int64_t block_size = 16) { HashProfile TestProfile() { return {.strategy = "vllm_v1", .algorithm = "sha256_cbor", + .python_hash_seed = "0", .root_digest = kRootDigest, .index_projection = "low64_be"}; } +HashProfile PaddedSeedProfile() { + return {.strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = "00", + .root_digest = kPaddedSeedRootDigest, + .index_projection = "low64_be"}; +} + EngineRegistration Registration(const std::string& instance_id = "instance-a", int64_t dp_rank = 0) { const ContextKey context = TestContext(); @@ -206,6 +217,32 @@ TEST(Registration, InvalidInputsDoNotCreateContextState) { EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table).contexts.empty()); } +TEST(Registration, ForgedSeedRootPairIsRejectedWithoutMutation) { + PrefixCacheTable table; + auto forged = Registration(); + forged.profile.root_digest = kPaddedSeedRootDigest; + + const auto validation = PrefixCacheTable::ValidateRegistration(forged); + EXPECT_NE(validation.error.find("does not match"), std::string::npos); + const auto rejected = table.Register(forged); + EXPECT_NE(rejected.error.find("does not match"), std::string::npos); + EXPECT_FALSE(rejected.inserted); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table).contexts.empty()); + + RegisterOrFail(table, Registration()); + const auto registered = PrefixCacheTableTestPeer::Snapshot(table); + EXPECT_NE(table.ValidateProfileBinding(TestContext(), forged.profile) + .find("does not match"), + std::string::npos); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), registered); + + forged.instance_id = "instance-b"; + const auto conflicting = table.Register(forged); + EXPECT_NE(conflicting.error.find("does not match"), std::string::npos); + EXPECT_FALSE(conflicting.inserted); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), registered); +} + TEST(Registration, TracksEveryInstanceAndRankIdempotently) { PrefixCacheTable table; @@ -247,7 +284,7 @@ TEST(Registration, ConflictingProfilePreservesCompleteState) { const auto before = PrefixCacheTableTestPeer::Snapshot(table); auto conflicting = Registration("instance-b", 1); - conflicting.profile.root_digest = std::string(64, '0'); + conflicting.profile = PaddedSeedProfile(); const auto result = table.Register(conflicting); EXPECT_FALSE(result.error.empty()); @@ -267,8 +304,7 @@ TEST(Registration, ProfileBindingValidationIsExactAndLookupOnly) { const auto registered = PrefixCacheTableTestPeer::Snapshot(table); EXPECT_EQ(table.ValidateProfileBinding(TestContext(), TestProfile()), ""); - HashProfile conflict = TestProfile(); - conflict.root_digest = std::string(64, '0'); + const HashProfile conflict = PaddedSeedProfile(); EXPECT_FALSE(table.ValidateProfileBinding(TestContext(), conflict).empty()); EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), registered); }