[sgl-router] refactor - config and organize CLI options (#39867)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -5,51 +5,29 @@ pub use cli::Cli;
|
|||||||
pub use sampling::*;
|
pub use sampling::*;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, ensure, Result};
|
||||||
|
|
||||||
/// The k8s default `terminationGracePeriodSeconds`, assumed when the operator
|
/// Default pod termination grace period when none is declared.
|
||||||
/// has not declared the pod's real one. A `shutdown_drain_secs` at or above the
|
|
||||||
/// grace period leaves no time for the in-flight drain, so the pod is SIGKILLed
|
|
||||||
/// before it finishes — the opposite of what the drain is for.
|
|
||||||
pub const K8S_DEFAULT_GRACE_SECS: u64 = 30;
|
pub const K8S_DEFAULT_GRACE_SECS: u64 = 30;
|
||||||
|
|
||||||
/// Ceiling on `shutdown_drain_secs`, enforced by [`Config::validate`]. Sized
|
/// Maximum shutdown pause; deployments must also allow time to drain in-flight requests.
|
||||||
/// for the workload rather than for the k8s default grace period: a single
|
/// This is the hard typo gate (an extra digit, seconds confused with milliseconds);
|
||||||
/// streaming completion can hold the router for many minutes, so a deployment
|
/// whether a legal drain fits a particular grace period is [`shutdown_drain_advisory`]'s
|
||||||
/// that does not want terminations cutting one off runs a
|
/// job, because the operator can raise the budget.
|
||||||
/// `terminationGracePeriodSeconds` in the tens of minutes and a drain to match.
|
|
||||||
/// Deciding whether a particular drain fits a particular grace period is
|
|
||||||
/// [`shutdown_drain_advisory`]'s job — advice, because the operator can raise
|
|
||||||
/// the budget. This constant is the separate, harder gate: it rejects a value
|
|
||||||
/// that is not a drain at all, an extra digit or seconds confused with
|
|
||||||
/// milliseconds, which no grace period could ever service.
|
|
||||||
pub const MAX_SHUTDOWN_DRAIN_SECS: u64 = 1800;
|
pub const MAX_SHUTDOWN_DRAIN_SECS: u64 = 1800;
|
||||||
|
|
||||||
/// A `shutdown_drain_secs` that leaves no room under the grace period for the
|
/// A shutdown pause that exhausts the declared or assumed pod grace period.
|
||||||
/// in-flight drain that follows the pause. Carries the compared values as
|
|
||||||
/// fields so the caller logs a static message with structured data rather than
|
|
||||||
/// interpolating the numbers into the message text.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ShutdownDrainAdvisory {
|
pub struct ShutdownDrainAdvisory {
|
||||||
pub shutdown_drain_secs: u64,
|
pub shutdown_drain_secs: u64,
|
||||||
/// The budget the drain was compared against.
|
/// The budget the drain was compared against.
|
||||||
pub termination_grace_secs: u64,
|
pub termination_grace_secs: u64,
|
||||||
/// Whether that budget came from the operator or from
|
/// Whether the operator declared the grace period.
|
||||||
/// [`K8S_DEFAULT_GRACE_SECS`]. An assumed budget makes the advisory a
|
|
||||||
/// guess; a declared one makes it a fact.
|
|
||||||
pub grace_declared: bool,
|
pub grace_declared: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advisory (not a hard error: the drain may well be right and the grace period
|
/// Warn when the pause leaves no time for in-flight draining.
|
||||||
/// raised to match) for a drain that leaves no room for the in-flight drain.
|
/// An undeclared grace period uses the Kubernetes default.
|
||||||
/// The bound is `>=`, not `>`: a drain of exactly the grace period already
|
|
||||||
/// consumes all of it.
|
|
||||||
///
|
|
||||||
/// `termination_grace_secs` is the pod's real `terminationGracePeriodSeconds`
|
|
||||||
/// when the operator declared it. The router cannot read its own pod spec, so
|
|
||||||
/// `None` falls back to the k8s default — which is why declaring the real value
|
|
||||||
/// is the way to silence this on a deployment that raised the grace period
|
|
||||||
/// deliberately, rather than lowering a drain that was correct.
|
|
||||||
pub fn shutdown_drain_advisory(
|
pub fn shutdown_drain_advisory(
|
||||||
shutdown_drain_secs: u64,
|
shutdown_drain_secs: u64,
|
||||||
termination_grace_secs: Option<u64>,
|
termination_grace_secs: Option<u64>,
|
||||||
@@ -63,51 +41,35 @@ pub fn shutdown_drain_advisory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Check invariants the type system and `clap` don't already enforce.
|
/// Validate invariants not enforced by the CLI parser.
|
||||||
/// Called by [`cli::Cli::into_config`] after assembling the `Config`
|
|
||||||
/// from flags. Unknown policy names and `--cb-threshold 0` are
|
|
||||||
/// rejected at parse time (`ValueEnum` / `NonZeroU32`); only the
|
|
||||||
/// remaining value-level invariants are checked here.
|
|
||||||
pub(crate) fn validate(&self) -> Result<()> {
|
pub(crate) fn validate(&self) -> Result<()> {
|
||||||
if self.model.id.is_empty() {
|
ensure!(!self.model.id.is_empty(), "model id must be non-empty");
|
||||||
return Err(anyhow!("model id must be non-empty"));
|
|
||||||
}
|
|
||||||
if let Some(bucket_config) = self.model.bucket_config.as_ref() {
|
if let Some(bucket_config) = self.model.bucket_config.as_ref() {
|
||||||
validate_bucket_config(bucket_config)?;
|
validate_bucket_config(bucket_config)?;
|
||||||
}
|
}
|
||||||
self.model.sampling_overrides.validate()?;
|
self.model.sampling_overrides.validate()?;
|
||||||
if self.server.shutdown_drain_secs > MAX_SHUTDOWN_DRAIN_SECS {
|
ensure!(
|
||||||
return Err(anyhow!(
|
self.server.shutdown_drain_secs <= MAX_SHUTDOWN_DRAIN_SECS,
|
||||||
"shutdown_drain_secs must be at most {MAX_SHUTDOWN_DRAIN_SECS} (got {}); \
|
"shutdown_drain_secs must be at most {MAX_SHUTDOWN_DRAIN_SECS} (got {}); \
|
||||||
past the ceiling a value is a typo rather than a drain, and the pod \
|
past the ceiling a value is a typo rather than a drain. A long but \
|
||||||
would be SIGKILLed long before the pause elapsed. A long but deliberate \
|
deliberate drain is fine — declare --termination-grace-secs so startup \
|
||||||
drain is fine — declare --termination-grace-secs so startup can check \
|
can check it against the pod's real budget",
|
||||||
it against the pod's real budget",
|
self.server.shutdown_drain_secs,
|
||||||
self.server.shutdown_drain_secs,
|
);
|
||||||
));
|
|
||||||
}
|
|
||||||
match &self.discovery {
|
match &self.discovery {
|
||||||
DiscoveryBackend::StaticUrls(s) => {
|
DiscoveryBackend::StaticUrls(s) => {
|
||||||
if s.urls.is_empty() {
|
ensure!(
|
||||||
return Err(anyhow!(
|
!s.urls.is_empty(),
|
||||||
"discovery.static_urls.urls must be a non-empty list"
|
"discovery.static_urls.urls must be a non-empty list"
|
||||||
));
|
);
|
||||||
}
|
// Normalize URLs before deduplication so trailing slashes cannot register a worker twice.
|
||||||
// Validate every entry up front so typos surface at
|
|
||||||
// startup with a precise diagnostic instead of as
|
|
||||||
// per-worker introspect failures or as two registry
|
|
||||||
// entries pointing at the same SGLang (trailing-slash
|
|
||||||
// near-duplicates). Dedupe runs against a normalized
|
|
||||||
// form (trimmed + trailing `/` stripped) so
|
|
||||||
// `"http://x:30000"` and `"http://x:30000/"` collide.
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
for raw in &s.urls {
|
for raw in &s.urls {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.is_empty() {
|
ensure!(
|
||||||
return Err(anyhow!(
|
!trimmed.is_empty(),
|
||||||
"discovery.static_urls.urls contains an empty or whitespace-only entry"
|
"discovery.static_urls.urls contains an empty or whitespace-only entry"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
let parsed = url::Url::parse(trimmed).map_err(|e| {
|
let parsed = url::Url::parse(trimmed).map_err(|e| {
|
||||||
anyhow!("discovery.static_urls.urls entry {raw:?} is not a valid URL: {e}")
|
anyhow!("discovery.static_urls.urls entry {raw:?} is not a valid URL: {e}")
|
||||||
})?;
|
})?;
|
||||||
@@ -120,17 +82,13 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let normalized = parsed.as_str().trim_end_matches('/').to_string();
|
let normalized = parsed.as_str().trim_end_matches('/').to_string();
|
||||||
if !seen.insert(normalized.clone()) {
|
ensure!(
|
||||||
return Err(anyhow!(
|
seen.insert(normalized.clone()),
|
||||||
"discovery.static_urls.urls contains duplicate entry {raw:?} (normalized: {normalized:?})"
|
"discovery.static_urls.urls contains duplicate entry {raw:?} (normalized: {normalized:?})"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// K8s selector validity is resolved at construction time
|
// Kubernetes selector combinations are validated by `resolve_mode` during construction.
|
||||||
// (`resolve_mode` in `Cli::build_discovery`), so the stored
|
|
||||||
// `K8sDiscoveryMode` is already valid here. Any namespace
|
|
||||||
// (including empty, for a cluster-wide watch) is accepted.
|
|
||||||
DiscoveryBackend::K8s(_) => {}
|
DiscoveryBackend::K8s(_) => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -138,50 +96,44 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_bucket_config(bucket_config: &BucketConfig) -> Result<()> {
|
fn validate_bucket_config(bucket_config: &BucketConfig) -> Result<()> {
|
||||||
if bucket_config.buckets.is_empty() {
|
ensure!(
|
||||||
return Err(anyhow!(
|
!bucket_config.buckets.is_empty(),
|
||||||
"bucket_config.buckets must be non-empty when configured"
|
"bucket_config.buckets must be non-empty when configured"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
let mut ids = std::collections::HashSet::new();
|
let mut ids = std::collections::HashSet::new();
|
||||||
let mut ranks = std::collections::HashSet::new();
|
let mut ranks = std::collections::HashSet::new();
|
||||||
let mut stage_workers = std::collections::HashSet::new();
|
let mut stage_workers = std::collections::HashSet::new();
|
||||||
let mut has_prefill_bucket = false;
|
let mut has_prefill_bucket = false;
|
||||||
for bucket in &bucket_config.buckets {
|
for bucket in &bucket_config.buckets {
|
||||||
has_prefill_bucket |= bucket.stage == BucketStage::Prefill;
|
has_prefill_bucket |= bucket.stage == BucketStage::Prefill;
|
||||||
if bucket.id.is_empty() || !ids.insert(bucket.id.as_str()) {
|
ensure!(
|
||||||
return Err(anyhow!(
|
!bucket.id.is_empty() && ids.insert(bucket.id.as_str()),
|
||||||
"bucket_config bucket id must be non-empty and unique: {:?}",
|
"bucket_config bucket id must be non-empty and unique: {:?}",
|
||||||
bucket.id
|
bucket.id
|
||||||
));
|
);
|
||||||
}
|
ensure!(
|
||||||
if !ranks.insert((bucket.stage, bucket.rank)) {
|
ranks.insert((bucket.stage, bucket.rank)),
|
||||||
return Err(anyhow!(
|
"bucket_config rank must be unique within each stage: {}",
|
||||||
"bucket_config rank must be unique within each stage: {}",
|
bucket.rank
|
||||||
bucket.rank
|
);
|
||||||
));
|
ensure!(
|
||||||
}
|
!bucket.worker_ids.is_empty(),
|
||||||
if bucket.worker_ids.is_empty() {
|
"bucket_config bucket {:?} has no worker_ids",
|
||||||
return Err(anyhow!(
|
bucket.id
|
||||||
"bucket_config bucket {:?} has no worker_ids",
|
);
|
||||||
bucket.id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut worker_ids = std::collections::HashSet::new();
|
let mut worker_ids = std::collections::HashSet::new();
|
||||||
for worker_id in &bucket.worker_ids {
|
for worker_id in &bucket.worker_ids {
|
||||||
if worker_id.is_empty() || !worker_ids.insert(worker_id.as_str()) {
|
ensure!(
|
||||||
return Err(anyhow!(
|
!worker_id.is_empty() && worker_ids.insert(worker_id.as_str()),
|
||||||
"bucket_config bucket {:?} has an empty or duplicate worker id",
|
"bucket_config bucket {:?} has an empty or duplicate worker id",
|
||||||
bucket.id
|
bucket.id
|
||||||
));
|
);
|
||||||
}
|
ensure!(
|
||||||
if !stage_workers.insert((bucket.stage, worker_id.as_str())) {
|
stage_workers.insert((bucket.stage, worker_id.as_str())),
|
||||||
return Err(anyhow!(
|
"bucket_config worker {:?} belongs to more than one {:?} bucket",
|
||||||
"bucket_config worker {:?} belongs to more than one {:?} bucket",
|
worker_id,
|
||||||
worker_id,
|
bucket.stage
|
||||||
bucket.stage
|
);
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
validate_range(
|
validate_range(
|
||||||
bucket.min_extend_tokens,
|
bucket.min_extend_tokens,
|
||||||
@@ -195,33 +147,28 @@ fn validate_bucket_config(bucket_config: &BucketConfig) -> Result<()> {
|
|||||||
&bucket.id,
|
&bucket.id,
|
||||||
"sequence",
|
"sequence",
|
||||||
)?;
|
)?;
|
||||||
if bucket.max_context_tokens == Some(0) {
|
ensure!(
|
||||||
return Err(anyhow!(
|
bucket.max_context_tokens != Some(0),
|
||||||
"bucket_config bucket {:?} max_context_tokens must be > 0",
|
"bucket_config bucket {:?} max_context_tokens must be > 0",
|
||||||
bucket.id
|
bucket.id
|
||||||
));
|
);
|
||||||
}
|
ensure!(
|
||||||
if bucket.ttft_p95_at_capacity_ms == Some(0) {
|
bucket.ttft_p95_at_capacity_ms != Some(0),
|
||||||
return Err(anyhow!(
|
"bucket_config bucket {:?} TTFT p95 must be > 0",
|
||||||
"bucket_config bucket {:?} TTFT p95 must be > 0",
|
bucket.id
|
||||||
bucket.id
|
);
|
||||||
));
|
ensure!(
|
||||||
}
|
bucket
|
||||||
if bucket
|
.tps_p05_at_capacity
|
||||||
.tps_p05_at_capacity
|
.is_none_or(|value| value.is_finite() && value > 0.0),
|
||||||
.is_some_and(|value| !value.is_finite() || value <= 0.0)
|
"bucket_config bucket {:?} TPS p05 must be finite and > 0",
|
||||||
{
|
bucket.id
|
||||||
return Err(anyhow!(
|
);
|
||||||
"bucket_config bucket {:?} TPS p05 must be finite and > 0",
|
ensure!(
|
||||||
bucket.id
|
bucket.max_pending_prefill_tokens != Some(0),
|
||||||
));
|
"bucket_config bucket {:?} max_pending_prefill_tokens must be > 0",
|
||||||
}
|
bucket.id
|
||||||
if bucket.max_pending_prefill_tokens == Some(0) {
|
);
|
||||||
return Err(anyhow!(
|
|
||||||
"bucket_config bucket {:?} max_pending_prefill_tokens must be > 0",
|
|
||||||
bucket.id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
match bucket.stage {
|
match bucket.stage {
|
||||||
BucketStage::Prefill
|
BucketStage::Prefill
|
||||||
if bucket.min_sequence_tokens.is_some()
|
if bucket.min_sequence_tokens.is_some()
|
||||||
@@ -247,20 +194,19 @@ fn validate_bucket_config(bucket_config: &BucketConfig) -> Result<()> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !has_prefill_bucket {
|
ensure!(
|
||||||
return Err(anyhow!(
|
has_prefill_bucket,
|
||||||
"bucket_config must contain at least one Prefill bucket; enabling Bucket routing otherwise leaves every request without a Prefill domain"
|
"bucket_config must contain at least one Prefill bucket; enabling Bucket \
|
||||||
));
|
routing otherwise leaves every request without a Prefill domain"
|
||||||
}
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_range(min: Option<u64>, max: Option<u64>, id: &str, name: &str) -> Result<()> {
|
fn validate_range(min: Option<u64>, max: Option<u64>, id: &str, name: &str) -> Result<()> {
|
||||||
if min.zip(max).is_some_and(|(min, max)| min > max) {
|
ensure!(
|
||||||
return Err(anyhow!(
|
min.zip(max).is_none_or(|(min, max)| min <= max),
|
||||||
"bucket_config bucket {id:?} has invalid {name} range: min > max"
|
"bucket_config bucket {id:?} has invalid {name} range: min > max"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,10 +214,6 @@ fn validate_range(min: Option<u64>, max: Option<u64>, id: &str, name: &str) -> R
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Build a minimal valid-shape `Config` with the given static worker
|
|
||||||
/// URLs and model id, so the `validate()` branches can be exercised
|
|
||||||
/// directly. CLI parsing and the static-vs-k8s mapping are covered in
|
|
||||||
/// the `cli` module tests; the k8s selector grammar in `types`.
|
|
||||||
fn cfg(model_id: &str, urls: &[&str]) -> Config {
|
fn cfg(model_id: &str, urls: &[&str]) -> Config {
|
||||||
Config {
|
Config {
|
||||||
server: ServerConfig::default(),
|
server: ServerConfig::default(),
|
||||||
@@ -549,19 +491,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shutdown_drain_advisory_is_silent_below_the_k8s_default_grace() {
|
fn shutdown_drain_advisory_is_silent_below_the_k8s_default_grace() {
|
||||||
// Anything strictly under the k8s default terminationGracePeriodSeconds
|
|
||||||
// (30 s) still leaves room for the in-flight drain, so it is safe
|
|
||||||
// without operator action.
|
|
||||||
assert!(shutdown_drain_advisory(29, None).is_none());
|
assert!(shutdown_drain_advisory(29, None).is_none());
|
||||||
assert!(shutdown_drain_advisory(0, None).is_none());
|
assert!(shutdown_drain_advisory(0, None).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default drain is exactly the assumed grace period, so out of the box
|
/// Pinned because this is the one case an operator meets without choosing it:
|
||||||
/// it leaves nothing for the in-flight drain and says so on every startup.
|
/// an edit to either constant that silenced the warning would change the default
|
||||||
/// Asserted rather than left implicit because it is the one case an
|
/// deployment's behaviour, and should have to say so here.
|
||||||
/// operator meets without choosing it: a later edit to either constant that
|
|
||||||
/// silenced the warning would be changing the default deployment's
|
|
||||||
/// behaviour, and should have to say so here.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_default_drain_warns_until_the_grace_period_is_raised() {
|
fn the_default_drain_warns_until_the_grace_period_is_raised() {
|
||||||
let advisory = shutdown_drain_advisory(default_shutdown_drain_secs(), None)
|
let advisory = shutdown_drain_advisory(default_shutdown_drain_secs(), None)
|
||||||
@@ -585,11 +521,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shutdown_drain_advisory_warns_once_the_drain_consumes_the_whole_grace() {
|
fn shutdown_drain_advisory_warns_once_the_drain_consumes_the_whole_grace() {
|
||||||
// A drain of exactly the 30 s k8s default leaves zero seconds for the
|
|
||||||
// in-flight drain, so the pod is SIGKILLed mid-drain — the boundary
|
|
||||||
// itself must warn, not just values past it. The ceiling is in the list
|
|
||||||
// because it is startable: `validate` accepts it, so the advisory is
|
|
||||||
// the only thing left to say it does not fit the default grace period.
|
|
||||||
for drain in [K8S_DEFAULT_GRACE_SECS, 120, MAX_SHUTDOWN_DRAIN_SECS] {
|
for drain in [K8S_DEFAULT_GRACE_SECS, 120, MAX_SHUTDOWN_DRAIN_SECS] {
|
||||||
let advisory = shutdown_drain_advisory(drain, None)
|
let advisory = shutdown_drain_advisory(drain, None)
|
||||||
.unwrap_or_else(|| panic!("{drain}s must warn"));
|
.unwrap_or_else(|| panic!("{drain}s must warn"));
|
||||||
@@ -602,9 +533,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The advisory's whole purpose is to be silenceable by declaring the real
|
|
||||||
/// budget: a 60 s drain under a 120 s grace period is a correct
|
|
||||||
/// configuration, and warning about it trains operators to ignore the line.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shutdown_drain_advisory_respects_a_declared_grace_period() {
|
fn shutdown_drain_advisory_respects_a_declared_grace_period() {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -624,18 +552,12 @@ mod tests {
|
|||||||
shutdown_drain_advisory(10, Some(10)).is_some(),
|
shutdown_drain_advisory(10, Some(10)).is_some(),
|
||||||
"a short declared grace period must still be compared against",
|
"a short declared grace period must still be compared against",
|
||||||
);
|
);
|
||||||
// The configuration the ceiling was raised for: a completion streaming
|
|
||||||
// for minutes wants a drain of minutes, under a grace period declared
|
|
||||||
// to match. That is correct, not merely tolerated, so it must be silent.
|
|
||||||
assert!(
|
assert!(
|
||||||
shutdown_drain_advisory(MAX_SHUTDOWN_DRAIN_SECS, Some(3600)).is_none(),
|
shutdown_drain_advisory(MAX_SHUTDOWN_DRAIN_SECS, Some(3600)).is_none(),
|
||||||
"a long drain under a grace period declared to cover it must not warn",
|
"a long drain under a grace period declared to cover it must not warn",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `validate` is the hard gate the advisory deliberately is not: past the
|
|
||||||
/// ceiling the value can only be a typo, and starting on it would make
|
|
||||||
/// every later termination a SIGKILL.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_rejects_a_shutdown_drain_past_the_ceiling() {
|
fn validate_rejects_a_shutdown_drain_past_the_ceiling() {
|
||||||
let mut config = cfg("qwen3-0.6b", &["http://10.0.0.1:30000"]);
|
let mut config = cfg("qwen3-0.6b", &["http://10.0.0.1:30000"]);
|
||||||
|
|||||||
@@ -1,58 +1,30 @@
|
|||||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
//! Fleet-wide sampling contract (`--override-sampling-params` /
|
//! Fleet sampling defaults and constraints. Custom JSON visitors preserve duplicate
|
||||||
//! `--sampling-param-conflict`): the parameters an operator fixes for every
|
//! keys so validation can reject them and report the offending parameter.
|
||||||
//! request this router serves, and what a request that disagrees gets.
|
|
||||||
//!
|
//!
|
||||||
//! WHY this is parsed by hand rather than with `serde(deny_unknown_fields)`:
|
//! The flag is read once, at startup, on a router that crash-loops if it is wrong,
|
||||||
//! the flag is read once, at startup, on a router that crash-loops if it is
|
//! so the message an operator reads out of `kubectl logs` is the whole debugging
|
||||||
//! wrong, so the message an operator reads out of `kubectl logs` is the whole
|
//! session: every rejection names the offending key, the value it saw, and the
|
||||||
//! debugging session. Every rejection here names the offending key, the value
|
//! domain it violated. (A `serde_json::Map` would keep only the last of a repeated
|
||||||
//! it saw, and the domain it violated. The same reasoning is why both the
|
//! key and silently enforce a value the operator did not write.)
|
||||||
//! outer object and a band are decoded as ordered ENTRIES instead of a
|
|
||||||
//! `serde_json::Map`: a map keeps only the last of a repeated key, so
|
|
||||||
//! `{"temperature": 0, "temperature": 1}` would start cleanly and enforce a
|
|
||||||
//! value the operator did not write.
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, ensure, Result};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// Sampling parameters fixed fleet-wide, and what to do with a request that
|
/// Fleet sampling defaults. Exact values fill absent fields; [`ConflictPolicy`]
|
||||||
/// disagrees.
|
/// determines whether differing client values are rejected or forwarded.
|
||||||
///
|
|
||||||
/// A configured parameter is always injected into the forwarded body when the
|
|
||||||
/// request OMITS it, so the engine's own defaults cannot drift from what the
|
|
||||||
/// operator declared. What differs between the two [`ConflictPolicy`] modes is
|
|
||||||
/// only the request that DOES send the field: `Reject` makes the value an
|
|
||||||
/// immutability contract (400 before admission — never a silent rewrite),
|
|
||||||
/// while `Allow` lets the client value through untouched, degrading the
|
|
||||||
/// configured value to a fleet-wide default.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct SamplingOverrides {
|
pub struct SamplingOverrides {
|
||||||
/// Configured parameters, keyed so enforcement and injection are one loop
|
/// Parameters in deterministic injection order.
|
||||||
/// over whatever the operator set instead of a per-field ladder repeated
|
|
||||||
/// at each site. Iterating a `BTreeMap` keyed by the field enum is what
|
|
||||||
/// fixes the order values are injected in, so a forwarded body is
|
|
||||||
/// byte-identical across runs.
|
|
||||||
pub params: BTreeMap<SamplingField, ParamSpec>,
|
pub params: BTreeMap<SamplingField, ParamSpec>,
|
||||||
/// Applies to every configured parameter: there is deliberately no
|
/// Conflict behavior shared by all configured parameters.
|
||||||
/// per-parameter mode, so an operator reads one knob off one manifest.
|
|
||||||
pub conflict: ConflictPolicy,
|
pub conflict: ConflictPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SamplingOverrides {
|
impl SamplingOverrides {
|
||||||
/// Re-check every invariant [`parse_sampling_overrides`] enforces, on an
|
/// Validate parsed and programmatically constructed overrides alike.
|
||||||
/// already-built value.
|
|
||||||
///
|
|
||||||
/// WHY this is separate from the parser: the parser turns a raw JSON
|
|
||||||
/// string into this struct and is reachable only from the CLI, but the
|
|
||||||
/// struct itself is reachable from anywhere — a test fixture, a future
|
|
||||||
/// config file, an admin API. [`crate::config::Config::validate`] calls
|
|
||||||
/// this so no such path can hold a spec the flag would have refused to
|
|
||||||
/// start with (an out-of-domain exact value, an inverted band whose
|
|
||||||
/// `contains` rejects every value, or a band under `allow`, which names
|
|
||||||
/// nothing to inject and rejects nothing).
|
|
||||||
pub(crate) fn validate(&self) -> Result<()> {
|
pub(crate) fn validate(&self) -> Result<()> {
|
||||||
for (&field, spec) in &self.params {
|
for (&field, spec) in &self.params {
|
||||||
validate_spec(field, spec, self.conflict)?;
|
validate_spec(field, spec, self.conflict)?;
|
||||||
@@ -65,10 +37,7 @@ impl SamplingOverrides {
|
|||||||
/// (`--sampling-param-conflict`).
|
/// (`--sampling-param-conflict`).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
||||||
pub enum ConflictPolicy {
|
pub enum ConflictPolicy {
|
||||||
/// 400 before admission, quoting the configured value. The default: the
|
/// Reject differing client values with 400 before admission.
|
||||||
/// point of declaring a fleet-wide sampling contract is usually that it
|
|
||||||
/// holds, and silently serving something other than what the client asked
|
|
||||||
/// for is the one behavior no client can detect.
|
|
||||||
#[default]
|
#[default]
|
||||||
Reject,
|
Reject,
|
||||||
/// Forward the client's value to the engine untouched. The configured
|
/// Forward the client's value to the engine untouched. The configured
|
||||||
@@ -80,30 +49,16 @@ pub enum ConflictPolicy {
|
|||||||
/// accepted ones.
|
/// accepted ones.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ParamSpec {
|
pub enum ParamSpec {
|
||||||
/// A single value: injected when the request omits the field, and under
|
/// Injected when absent; under [`ConflictPolicy::Reject`], the only accepted value.
|
||||||
/// [`ConflictPolicy::Reject`] the only value a request may send.
|
/// JSON numbers preserve integer wire types.
|
||||||
///
|
|
||||||
/// Held as the parsed JSON number rather than an `f64` so injection
|
|
||||||
/// re-emits the operator's literal — `"n": 1` stays `1` and does not
|
|
||||||
/// become `1.0` on the wire for the integer-typed fields.
|
|
||||||
Exact(serde_json::Number),
|
Exact(serde_json::Number),
|
||||||
/// An inclusive `[lo, hi]` band of accepted values, for a contract that
|
/// Inclusive bounds for supplied values; never injects a default.
|
||||||
/// fixes most sampling knobs but leaves one tunable inside a range. A band
|
/// Requires [`ConflictPolicy::Reject`]. Omitted fields use the engine default,
|
||||||
/// names no single value, so it never injects; it only rejects
|
/// which may lie outside the band.
|
||||||
/// out-of-band values, which is why a band under [`ConflictPolicy::Allow`]
|
|
||||||
/// is a startup error rather than a no-op.
|
|
||||||
///
|
|
||||||
/// A band therefore constrains only the requests that NAME the parameter.
|
|
||||||
/// A request that omits it gets the model's own default (the engine reads
|
|
||||||
/// `generation_config`), which the router cannot see and which may itself
|
|
||||||
/// lie outside the band. An operator who needs the omitting majority
|
|
||||||
/// pinned too wants an exact value, not a band.
|
|
||||||
Range { lo: f64, hi: f64 },
|
Range { lo: f64, hi: f64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sampling parameter that can be fixed fleet-wide. The enum is what makes a
|
/// Supported fleet sampling parameters.
|
||||||
/// typo in the `--override-sampling-params` JSON a startup error instead of a
|
|
||||||
/// key that silently never matches a request field.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum SamplingField {
|
pub enum SamplingField {
|
||||||
Temperature,
|
Temperature,
|
||||||
@@ -129,15 +84,7 @@ impl SamplingField {
|
|||||||
Self::N,
|
Self::N,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// This field's slot in [`Self::ALL`], and in the request probe's
|
/// Slot in [`Self::ALL`] and in the request probe array.
|
||||||
/// fixed-size array of probed values.
|
|
||||||
///
|
|
||||||
/// Declaration order IS the slot order, so this cannot assign a wrong
|
|
||||||
/// one. What still needs checking is that [`Self::ALL`] agrees — see the
|
|
||||||
/// assertion below; `from_wire_name` and `supported_fields` both iterate
|
|
||||||
/// `ALL`, so a field missing from it is rejected at startup as an unknown
|
|
||||||
/// key, the failure that is invisible to any test that also iterates
|
|
||||||
/// `ALL`.
|
|
||||||
pub const fn index(self) -> usize {
|
pub const fn index(self) -> usize {
|
||||||
self as usize
|
self as usize
|
||||||
}
|
}
|
||||||
@@ -190,12 +137,11 @@ pub(crate) fn parse_sampling_overrides(
|
|||||||
'{{\"temperature\": 1, \"top_p\": 0.95}}': {e}"
|
'{{\"temperature\": 1, \"top_p\": 0.95}}': {e}"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if entries.is_empty() {
|
ensure!(
|
||||||
return Err(anyhow!(
|
!entries.is_empty(),
|
||||||
"--override-sampling-params is empty: pass at least one of {}, or omit the flag",
|
"--override-sampling-params is empty: pass at least one of {}, or omit the flag",
|
||||||
supported_fields()
|
supported_fields()
|
||||||
));
|
);
|
||||||
}
|
|
||||||
let mut params = BTreeMap::new();
|
let mut params = BTreeMap::new();
|
||||||
for (key, value) in entries {
|
for (key, value) in entries {
|
||||||
let field = SamplingField::from_wire_name(&key).ok_or_else(|| {
|
let field = SamplingField::from_wire_name(&key).ok_or_else(|| {
|
||||||
@@ -216,26 +162,20 @@ pub(crate) fn parse_sampling_overrides(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if params.insert(field, spec).is_some() {
|
ensure!(
|
||||||
return Err(anyhow!(
|
params.insert(field, spec).is_none(),
|
||||||
"--override-sampling-params: {} is set more than once",
|
"--override-sampling-params: {} is set more than once",
|
||||||
field.wire_name()
|
field.wire_name()
|
||||||
));
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let overrides = SamplingOverrides { params, conflict };
|
let overrides = SamplingOverrides { params, conflict };
|
||||||
// Re-checks the domains `checked_value` already covered above. That first
|
// Validate complete specs too, including bands and programmatically built overrides.
|
||||||
// pass is not redundant: it is what quotes the operator's own literal
|
// The earlier exact-value check protects the integer cast in `canonical_number`.
|
||||||
// (`1e2`, not `100`) and what guards `canonical_number`'s saturating
|
|
||||||
// `as i64` cast before it runs. This pass is what a hand-built
|
|
||||||
// `SamplingOverrides` gets, and is the only check a band's bounds see.
|
|
||||||
overrides.validate()?;
|
overrides.validate()?;
|
||||||
Ok(overrides)
|
Ok(overrides)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check one already-built spec. Shared by [`parse_sampling_overrides`] and
|
/// Validate a spec independently of how it was constructed.
|
||||||
/// [`SamplingOverrides::validate`] so a hand-built `SamplingOverrides` is held
|
|
||||||
/// to exactly the domain the flag is.
|
|
||||||
fn validate_spec(field: SamplingField, spec: &ParamSpec, conflict: ConflictPolicy) -> Result<()> {
|
fn validate_spec(field: SamplingField, spec: &ParamSpec, conflict: ConflictPolicy) -> Result<()> {
|
||||||
let key = field.wire_name();
|
let key = field.wire_name();
|
||||||
match spec {
|
match spec {
|
||||||
@@ -245,31 +185,24 @@ fn validate_spec(field: SamplingField, spec: &ParamSpec, conflict: ConflictPolic
|
|||||||
&ParamSpec::Range { lo, hi } => {
|
&ParamSpec::Range { lo, hi } => {
|
||||||
check_domain(field, lo, &lo.to_string())?;
|
check_domain(field, lo, &lo.to_string())?;
|
||||||
check_domain(field, hi, &hi.to_string())?;
|
check_domain(field, hi, &hi.to_string())?;
|
||||||
if lo > hi {
|
ensure!(
|
||||||
return Err(anyhow!(
|
lo <= hi,
|
||||||
"--override-sampling-params: {key} band needs min <= max, got min {lo} > max {hi}"
|
"--override-sampling-params: {key} band needs min <= max, got min {lo} > max {hi}"
|
||||||
));
|
);
|
||||||
}
|
// `top_k = -1` disables filtering; it cannot bound a band that would admit zero.
|
||||||
// Bounds are checked one at a time, which is only sufficient for a
|
ensure!(
|
||||||
// contiguous domain. `top_k`'s is not ({-1} U [1, inf)): `{"min": -1,
|
field != SamplingField::TopK || lo >= 1.0,
|
||||||
// "max": 100}` has two individually legal bounds and would admit
|
"--override-sampling-params: top_k band bounds must both be >= 1 \
|
||||||
// `top_k: 0`, which is rejected as an exact value. -1 is a sentinel,
|
|
||||||
// not a range endpoint.
|
|
||||||
if field == SamplingField::TopK && lo < 1.0 {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"--override-sampling-params: top_k band bounds must both be >= 1 \
|
|
||||||
(-1 disables top_k entirely and cannot bound a range)"
|
(-1 disables top_k entirely and cannot bound a range)"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
// A band only ever rejects, so under `allow` it would be dead config
|
// A band only ever rejects, so under `allow` it would be dead config
|
||||||
// that silently accepts everything.
|
// that silently accepts everything.
|
||||||
if conflict == ConflictPolicy::Allow {
|
ensure!(
|
||||||
return Err(anyhow!(
|
conflict == ConflictPolicy::Reject,
|
||||||
"--override-sampling-params: the {key} band requires \
|
"--override-sampling-params: the {key} band requires \
|
||||||
--sampling-param-conflict reject — under `allow` nothing is rejected \
|
--sampling-param-conflict reject — under `allow` nothing is rejected \
|
||||||
and a band names no value to inject"
|
and a band names no value to inject"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -294,11 +227,10 @@ fn parse_band(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if slot.is_some() {
|
ensure!(
|
||||||
return Err(anyhow!(
|
slot.is_none(),
|
||||||
"--override-sampling-params: {key} band sets \"{bound}\" more than once"
|
"--override-sampling-params: {key} band sets \"{bound}\" more than once"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
let serde_json::Value::Number(n) = v else {
|
let serde_json::Value::Number(n) = v else {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"--override-sampling-params: {key} band needs numeric bounds, got {bound}: {v}"
|
"--override-sampling-params: {key} band needs numeric bounds, got {bound}: {v}"
|
||||||
@@ -312,96 +244,61 @@ fn parse_band(
|
|||||||
{{\"min\": LO, \"max\": HI}} with numeric bounds"
|
{{\"min\": LO, \"max\": HI}} with numeric bounds"
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
// `lo <= hi`, `top_k`'s discontiguous domain and the band-under-`allow`
|
// Finished-spec constraints are checked by `validate_spec` for all construction paths.
|
||||||
// rule are all properties of the finished spec, so they live in
|
|
||||||
// `validate_spec` and hold for a hand-built `SamplingOverrides` too.
|
|
||||||
Ok(ParamSpec::Range { lo, hi })
|
Ok(ParamSpec::Range { lo, hi })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check one configured value against its parameter's domain, at startup
|
/// Validate before normalization so diagnostics retain the configured number.
|
||||||
/// instead of per request. Written as positive containment so a NaN bound
|
/// Domains follow the OpenAI contract plus engine-specific parameters — deliberately
|
||||||
/// fails too.
|
/// NARROWER than what the engine accepts: these values are injected into request
|
||||||
///
|
/// bodies, and a fleet contract outside the range every OpenAI client library
|
||||||
/// These are the OpenAI API's domains, which are NARROWER than what the engine
|
/// validates against is far more likely a typo than an intent.
|
||||||
/// itself accepts (`SamplingParams.verify` requires only that `temperature` be
|
|
||||||
/// non-negative and finite, so it would take `temperature: 5`). Narrower is
|
|
||||||
/// deliberate: the values here are injected into request bodies, and a fleet
|
|
||||||
/// contract outside the range every OpenAI client library validates against is
|
|
||||||
/// far more likely a typo than an intent. The one exception is `top_k`, where
|
|
||||||
/// `-1` is the engine's own "disable / whole vocabulary" spelling and its
|
|
||||||
/// default — a legitimate thing to fix fleet-wide. Note `top_k: 1` is greedy
|
|
||||||
/// decoding, NOT "disabled".
|
|
||||||
fn checked_value(field: SamplingField, n: &serde_json::Number) -> Result<f64> {
|
fn checked_value(field: SamplingField, n: &serde_json::Number) -> Result<f64> {
|
||||||
let name = field.wire_name();
|
let name = field.wire_name();
|
||||||
// `as_f64` is infallible for a JSON number unless serde_json's
|
// Handle conversion failure even if serde_json arbitrary precision is enabled later.
|
||||||
// `arbitrary_precision` is on (it is not); kept total rather than
|
|
||||||
// `expect`-ing, so enabling that feature can't turn config into a panic.
|
|
||||||
let v = n.as_f64().ok_or_else(|| {
|
let v = n.as_f64().ok_or_else(|| {
|
||||||
anyhow!("--override-sampling-params: {name} ({n}) is not a finite number")
|
anyhow!("--override-sampling-params: {name} ({n}) is not a finite number")
|
||||||
})?;
|
})?;
|
||||||
// The operator's own literal is what the message quotes, not the parsed
|
|
||||||
// f64: `1e2` should read back as `1e2`.
|
|
||||||
check_domain(field, v, &n.to_string())?;
|
check_domain(field, v, &n.to_string())?;
|
||||||
Ok(v)
|
Ok(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The domain half of [`checked_value`], over an f64 that may not have come
|
/// Validate a numeric domain; `shown` is the value quoted in diagnostics.
|
||||||
/// from a literal (a band's bounds are stored as f64). `shown` is what the
|
|
||||||
/// error quotes back to the operator.
|
|
||||||
fn check_domain(field: SamplingField, v: f64, shown: &str) -> Result<()> {
|
fn check_domain(field: SamplingField, v: f64, shown: &str) -> Result<()> {
|
||||||
let name = field.wire_name();
|
let name = field.wire_name();
|
||||||
let (ok, domain) = match field {
|
let (ok, domain) = match field {
|
||||||
SamplingField::Temperature => ((0.0..=2.0).contains(&v), "in [0, 2]"),
|
SamplingField::Temperature => ((0.0..=2.0).contains(&v), "in [0, 2]"),
|
||||||
SamplingField::TopP => (v > 0.0 && v <= 1.0, "in (0, 1]"),
|
SamplingField::TopP => (v > 0.0 && v <= 1.0, "in (0, 1]"),
|
||||||
SamplingField::TopK => (v >= 1.0 || v == -1.0, ">= 1, or -1 to disable"),
|
SamplingField::TopK => (v >= 1.0 || v == -1.0, ">= 1, or -1 to disable"),
|
||||||
// Not an OpenAI parameter: `min_p` is the engine's own nucleus floor,
|
// Engine-specific: zero disables `min_p`.
|
||||||
// and 0 is its default (disabled), so the whole [0, 1] range is
|
|
||||||
// legitimate to fix fleet-wide.
|
|
||||||
SamplingField::MinP => ((0.0..=1.0).contains(&v), "in [0, 1]"),
|
SamplingField::MinP => ((0.0..=1.0).contains(&v), "in [0, 1]"),
|
||||||
// Also engine-only. 1.0 is "no penalty"; the engine requires > 0, and
|
// Engine-specific: one disables the penalty; cap fleet defaults at two.
|
||||||
// values above ~2 degrade output badly enough that a fleet-wide pin
|
|
||||||
// there is far more likely a typo than an intent.
|
|
||||||
SamplingField::RepetitionPenalty => (v > 0.0 && v <= 2.0, "in (0, 2]"),
|
SamplingField::RepetitionPenalty => (v > 0.0 && v <= 2.0, "in (0, 2]"),
|
||||||
SamplingField::FrequencyPenalty | SamplingField::PresencePenalty => {
|
SamplingField::FrequencyPenalty | SamplingField::PresencePenalty => {
|
||||||
((-2.0..=2.0).contains(&v), "in [-2, 2]")
|
((-2.0..=2.0).contains(&v), "in [-2, 2]")
|
||||||
}
|
}
|
||||||
// OpenAI caps `n` at 128. Unbounded here, a typo'd digit would be
|
// Bound sequence fan-out when `n` is injected into requests.
|
||||||
// injected into every request that omits `n` and fan each one out to
|
|
||||||
// that many sequences at the engine — the exact per-request failure
|
|
||||||
// this startup check exists to convert into a launch failure.
|
|
||||||
SamplingField::N => ((1.0..=128.0).contains(&v), "in [1, 128]"),
|
SamplingField::N => ((1.0..=128.0).contains(&v), "in [1, 128]"),
|
||||||
};
|
};
|
||||||
if !ok {
|
ensure!(
|
||||||
return Err(anyhow!(
|
ok,
|
||||||
"--override-sampling-params: {name} ({shown}) must be {domain}"
|
"--override-sampling-params: {name} ({shown}) must be {domain}"
|
||||||
));
|
);
|
||||||
}
|
|
||||||
if field.is_integral() {
|
if field.is_integral() {
|
||||||
if v.fract() != 0.0 {
|
ensure!(
|
||||||
return Err(anyhow!(
|
v.fract() == 0.0,
|
||||||
"--override-sampling-params: {name} ({shown}) must be a whole number"
|
"--override-sampling-params: {name} ({shown}) must be a whole number"
|
||||||
));
|
);
|
||||||
}
|
// Float-to-i64 casts saturate. Use [-2^63, 2^63): `i64::MAX as f64` rounds up.
|
||||||
// `canonical_number` casts to `i64`, and a Rust float-to-int cast
|
ensure!(
|
||||||
// SATURATES rather than failing, so a literal past the i64 range would
|
(i64::MIN as f64..i64::MAX as f64).contains(&v),
|
||||||
// silently become `i64::MAX` in every forwarded body. The exactly
|
"--override-sampling-params: {name} ({shown}) is too large to forward"
|
||||||
// convertible f64s are [-2^63, 2^63), which is this half-open range
|
);
|
||||||
// written as positive containment — `i64::MAX as f64` rounds UP to
|
|
||||||
// 2^63, so an inclusive `<=` against it would admit 2^63 itself and
|
|
||||||
// saturate exactly as described.
|
|
||||||
if !(i64::MIN as f64..i64::MAX as f64).contains(&v) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"--override-sampling-params: {name} ({shown}) is too large to forward"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize an integer-typed parameter's literal so injection writes `1`
|
/// Emit integer-typed parameters as integers; preserve other JSON numbers.
|
||||||
/// rather than `1.0` for a config that spelled it `1.0` — the engine types
|
|
||||||
/// these fields as `int`, and the forwarded body should look like what a
|
|
||||||
/// client would have sent. Non-integral fields keep the operator's literal.
|
|
||||||
fn canonical_number(
|
fn canonical_number(
|
||||||
field: SamplingField,
|
field: SamplingField,
|
||||||
value: f64,
|
value: f64,
|
||||||
@@ -423,8 +320,7 @@ fn supported_fields() -> String {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A JSON object decoded to its entries IN ORDER, keeping a repeated key
|
/// Ordered JSON entries preserve duplicate keys for validation.
|
||||||
/// instead of collapsing it. See the module WHY note.
|
|
||||||
struct ObjectEntries(Vec<(String, ParamValue)>);
|
struct ObjectEntries(Vec<(String, ParamValue)>);
|
||||||
|
|
||||||
impl<'de> serde::Deserialize<'de> for ObjectEntries {
|
impl<'de> serde::Deserialize<'de> for ObjectEntries {
|
||||||
@@ -454,10 +350,7 @@ impl<'de> serde::Deserialize<'de> for ObjectEntries {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One parameter's raw value: a number, a band's entries, or anything else.
|
/// Raw parameter value; `Other` retains invalid values for precise diagnostics.
|
||||||
/// `Other` keeps the offending value so the caller can name it, rather than
|
|
||||||
/// degrading a wrong-type message into a serde type error behind the outer
|
|
||||||
/// object's context.
|
|
||||||
enum ParamValue {
|
enum ParamValue {
|
||||||
Number(serde_json::Number),
|
Number(serde_json::Number),
|
||||||
Band(Vec<(String, serde_json::Value)>),
|
Band(Vec<(String, serde_json::Value)>),
|
||||||
@@ -510,9 +403,7 @@ impl<'de> serde::Deserialize<'de> for ParamValue {
|
|||||||
Ok(ParamValue::Other(v.into()))
|
Ok(ParamValue::Other(v.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON `null`. There is deliberately no `visit_none`: this type
|
/// `deserialize_any` routes JSON null to `visit_unit`.
|
||||||
/// is only ever reached through `deserialize_any`, which routes
|
|
||||||
/// null here and never to the `Option` hook.
|
|
||||||
fn visit_unit<E>(self) -> Result<ParamValue, E> {
|
fn visit_unit<E>(self) -> Result<ParamValue, E> {
|
||||||
Ok(ParamValue::Other(serde_json::Value::Null))
|
Ok(ParamValue::Other(serde_json::Value::Null))
|
||||||
}
|
}
|
||||||
@@ -654,18 +545,12 @@ mod tests {
|
|||||||
assert_eq!(exact_of(&o, SamplingField::TopK), Some(1000.0));
|
assert_eq!(exact_of(&o, SamplingField::TopK), Some(1000.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `top_k: -1` is the engine's own "disable / whole vocabulary" spelling
|
|
||||||
/// (and its default), so a fleet may legitimately fix `top_k` to it —
|
|
||||||
/// unlike every other parameter, whose domain is the OpenAI one.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_k_accepts_the_engines_disable_sentinel() {
|
fn top_k_accepts_the_engines_disable_sentinel() {
|
||||||
let o = parse(r#"{"top_k": -1}"#).unwrap();
|
let o = parse(r#"{"top_k": -1}"#).unwrap();
|
||||||
assert_eq!(exact_of(&o, SamplingField::TopK), Some(-1.0));
|
assert_eq!(exact_of(&o, SamplingField::TopK), Some(-1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An integer-typed parameter spelled as a float is normalized, so the
|
|
||||||
/// forwarded body carries `1` and not `1.0` for a field the engine types
|
|
||||||
/// as `int`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn integral_params_are_normalized_to_integers() {
|
fn integral_params_are_normalized_to_integers() {
|
||||||
let o = parse(r#"{"n": 1.0, "top_k": 20.0}"#).unwrap();
|
let o = parse(r#"{"n": 1.0, "top_k": 20.0}"#).unwrap();
|
||||||
@@ -689,11 +574,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(SamplingField::from_wire_name("max_tokens"), None);
|
assert_eq!(SamplingField::from_wire_name("max_tokens"), None);
|
||||||
}
|
}
|
||||||
/// `canonical_number` casts to `i64` and a Rust float-to-int cast
|
/// Float-to-i64 casts saturate. Use [-2^63, 2^63): `i64::MAX as f64` rounds up.
|
||||||
/// SATURATES, so a literal past the i64 range must fail the launch rather
|
|
||||||
/// than be injected as `i64::MAX`. The boundary case is the trap: `i64::MAX
|
|
||||||
/// as f64` rounds UP to 2^63, so a `>` comparison against it admits 2^63
|
|
||||||
/// itself.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn integral_literals_beyond_i64_fail_the_launch() {
|
fn integral_literals_beyond_i64_fail_the_launch() {
|
||||||
for raw in [
|
for raw in [
|
||||||
@@ -716,12 +597,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `min_p` and `repetition_penalty` are the only two parameters besides
|
|
||||||
/// `temperature`/`top_p`/`top_k` that the engine resolves from the model's
|
|
||||||
/// own `generation_config`, so they are exactly the ones a fleet-wide pin
|
|
||||||
/// exists to stop drifting when an image is swapped. Rejecting them as
|
|
||||||
/// unknown keys would crash-loop the router for the operator who needs the
|
|
||||||
/// flag most.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn governs_the_engine_defaulted_parameters() {
|
fn governs_the_engine_defaulted_parameters() {
|
||||||
let o = parse(r#"{"min_p": 0.05, "repetition_penalty": 1.1}"#).unwrap();
|
let o = parse(r#"{"min_p": 0.05, "repetition_penalty": 1.1}"#).unwrap();
|
||||||
@@ -744,16 +619,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `ALL` is what `from_wire_name` and `supported_fields` iterate, so a
|
|
||||||
/// field missing from it is silently rejected at startup as an unknown
|
|
||||||
/// key — invisible to any test that also iterates `ALL`. Pin the length
|
|
||||||
/// and the slot mapping against the wire names instead.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_covers_every_field_exactly_once() {
|
fn all_covers_every_field_exactly_once() {
|
||||||
// The slot mapping itself is asserted at compile time (see the
|
|
||||||
// `const _` block above `parse_sampling_overrides`); what only a test
|
|
||||||
// can catch is a field missing from `ALL` entirely, which is why the
|
|
||||||
// names below are written out rather than derived from it.
|
|
||||||
let names: std::collections::BTreeSet<_> =
|
let names: std::collections::BTreeSet<_> =
|
||||||
SamplingField::ALL.iter().map(|f| f.wire_name()).collect();
|
SamplingField::ALL.iter().map(|f| f.wire_name()).collect();
|
||||||
assert_eq!(names.len(), SamplingField::ALL.len(), "duplicate wire name");
|
assert_eq!(names.len(), SamplingField::ALL.len(), "duplicate wire name");
|
||||||
@@ -771,10 +638,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The struct-level invariants must hold for a `SamplingOverrides` that
|
|
||||||
/// never went through the parser — a test fixture, a future config file or
|
|
||||||
/// admin API. An inverted band is the nastiest of these: `(lo..=hi)`
|
|
||||||
/// contains nothing, so it would 400 every request naming the parameter.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_rejects_hand_built_specs_the_parser_would_refuse() {
|
fn validate_rejects_hand_built_specs_the_parser_would_refuse() {
|
||||||
let bad = [
|
let bad = [
|
||||||
|
|||||||
@@ -2,32 +2,22 @@ use crate::config::sampling::SamplingOverrides;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
|
|
||||||
/// In-memory router configuration, built from CLI flags by
|
/// Single-model configuration built and validated by [`crate::config::Cli::into_config`].
|
||||||
/// [`crate::config::cli::Cli::into_config`] and validated by
|
|
||||||
/// [`Config::validate`]. The router serves exactly one model.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
pub observability: ObservabilityConfig,
|
pub observability: ObservabilityConfig,
|
||||||
pub model: ModelConfig,
|
pub model: ModelConfig,
|
||||||
/// Selected discovery backend. Built from CLI flags by
|
/// Discovery mode resolved from CLI options; static URLs are checked by [`Config::validate`].
|
||||||
/// [`crate::config::cli::Cli::into_config`]: the static-vs-k8s choice
|
|
||||||
/// and the k8s selector grammar are resolved there (the latter via
|
|
||||||
/// [`resolve_mode`]); static worker-URL validity is checked by
|
|
||||||
/// [`Config::validate`].
|
|
||||||
pub discovery: DiscoveryBackend,
|
pub discovery: DiscoveryBackend,
|
||||||
pub proxy: ProxyConfig,
|
pub proxy: ProxyConfig,
|
||||||
pub active_load: ActiveLoadConfig,
|
pub active_load: ActiveLoadConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outbound proxy tuning. Default mirrors SGLang's typical prefill /
|
/// Outbound request timeout settings.
|
||||||
/// decode latency budget; e2e tests lower it so per-request failures
|
|
||||||
/// trip the circuit breaker within the test's wall-time.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct ProxyConfig {
|
pub struct ProxyConfig {
|
||||||
/// Maximum time to wait for a single upstream HTTP request to
|
/// Timeout for upstream response headers and body. Counts as a circuit-breaker failure.
|
||||||
/// return headers + body. Default 300 s. The circuit breaker
|
|
||||||
/// records a failure when this fires.
|
|
||||||
pub request_timeout_secs: u64,
|
pub request_timeout_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,15 +33,10 @@ impl Default for ProxyConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Active-load (per-request) tracking. Production default (10 min)
|
/// Request-tracking timeout; defaults above the proxy timeout.
|
||||||
/// sits above `proxy.request_timeout_secs` so the proxy timeout is the
|
|
||||||
/// one users hit first for normal slow upstreams; tests lower it to
|
|
||||||
/// let the janitor fire within their wall-time budget.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct ActiveLoadConfig {
|
pub struct ActiveLoadConfig {
|
||||||
/// How long a request entry can live in the registry before the
|
/// Maximum request-entry lifetime before cancellation with 504 `stale_request_expired`.
|
||||||
/// janitor fires its `cancel_token` and the chat handler returns
|
|
||||||
/// 504 `stale_request_expired`. Default 600 s.
|
|
||||||
pub stale_request_timeout_secs: u64,
|
pub stale_request_timeout_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +52,7 @@ impl Default for ActiveLoadConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Routing policy selector — the enum form lets `clap` reject unknown
|
/// Routing strategies accepted by `--policy`.
|
||||||
/// values at parse time and removes the runtime string match in the
|
|
||||||
/// policy factory.
|
|
||||||
///
|
|
||||||
/// Accepted on the CLI (`--policy`) as `round_robin` / `random` /
|
|
||||||
/// `power_of_two` / `load_based` / `fused_score` / `score_policy` /
|
|
||||||
/// `session_aware` / `cache_aware` / `sticky`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
||||||
pub enum PolicyKind {
|
pub enum PolicyKind {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -98,11 +77,7 @@ pub enum PolicyKind {
|
|||||||
/// Selects cache-affine prefill candidates from the configured prefix provider.
|
/// Selects cache-affine prefill candidates from the configured prefix provider.
|
||||||
#[value(name = "cache_aware")]
|
#[value(name = "cache_aware")]
|
||||||
CacheAware,
|
CacheAware,
|
||||||
/// Sticky-session routing: pins a routing key (read from a
|
/// Pin a request-header routing key to a worker.
|
||||||
/// configurable request header) to a worker via an in-memory map, so
|
|
||||||
/// stateful sessions land on the same backend. Tuning — header name,
|
|
||||||
/// keyless-fallback policy, and TTL eviction — lives on
|
|
||||||
/// `ModelConfig::sticky`.
|
|
||||||
#[value(name = "sticky")]
|
#[value(name = "sticky")]
|
||||||
Sticky,
|
Sticky,
|
||||||
}
|
}
|
||||||
@@ -248,35 +223,16 @@ impl std::fmt::Display for StickyFallbackKind {
|
|||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Seconds to keep serving after SIGTERM — with `/readyz` flipped to 503 —
|
/// Pause after SIGTERM with `/readyz` returning 503 before stopping accepts.
|
||||||
/// before the HTTP server stops accepting. The default covers both
|
/// Allows endpoint removal or readiness-probe failures to reach load balancers.
|
||||||
/// deregistration paths: endpoint removal reaching kube-proxy after the
|
/// Leave time in the pod grace period for in-flight draining; 0 disables the pause.
|
||||||
/// pod's `deletionTimestamp` is stamped, and a probe-driven load balancer,
|
|
||||||
/// which cannot act until `failureThreshold * periodSeconds` of `/readyz`
|
|
||||||
/// failures have accumulated.
|
|
||||||
///
|
|
||||||
/// Note that it equals the k8s default `terminationGracePeriodSeconds`, so
|
|
||||||
/// a pod that has not raised its grace period is left with nothing for the
|
|
||||||
/// in-flight drain that follows the pause, and
|
|
||||||
/// [`shutdown_drain_advisory`](crate::config::shutdown_drain_advisory)
|
|
||||||
/// warns at every startup. That is the intended reading rather than a
|
|
||||||
/// misconfigured default: a router whose completions stream for minutes
|
|
||||||
/// cannot terminate cleanly inside 30 s at all, and the grace period is the
|
|
||||||
/// thing to raise. 0 disables the pause.
|
|
||||||
pub shutdown_drain_secs: u64,
|
pub shutdown_drain_secs: u64,
|
||||||
/// The pod's actual `terminationGracePeriodSeconds`, when the operator
|
/// Declared pod termination grace period; `None` uses the Kubernetes default for advisories.
|
||||||
/// declares it. The router cannot read its own pod spec, so without this
|
|
||||||
/// the startup advisory can only compare the drain against the k8s
|
|
||||||
/// default — and warns, wrongly, about a deployment that raised the grace
|
|
||||||
/// period on purpose. `None` means "assume the default".
|
|
||||||
pub termination_grace_secs: Option<u64>,
|
pub termination_grace_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
impl ServerConfig {
|
||||||
/// [`Self::shutdown_drain_secs`] as a `Duration`. Keeps the seconds-to-
|
/// Shutdown pause as a duration.
|
||||||
/// `Duration` conversion in the library, where a test can pin it, rather
|
|
||||||
/// than in `main.rs` where a `from_secs`/`from_millis` slip would silently
|
|
||||||
/// shorten every drain by a factor of 1000.
|
|
||||||
pub fn shutdown_drain(&self) -> std::time::Duration {
|
pub fn shutdown_drain(&self) -> std::time::Duration {
|
||||||
std::time::Duration::from_secs(self.shutdown_drain_secs)
|
std::time::Duration::from_secs(self.shutdown_drain_secs)
|
||||||
}
|
}
|
||||||
@@ -294,10 +250,7 @@ pub fn default_shutdown_drain_secs() -> u64 {
|
|||||||
30
|
30
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exists so test fixtures can spell out only the fields they care about
|
/// Defaults for config construction; the CLI mapping remains exhaustive.
|
||||||
/// (`tests/` is a separate crate, so a `#[cfg(test)]` constructor cannot reach
|
|
||||||
/// the integration fixtures). Keep `Cli::into_config` exhaustive so adding a
|
|
||||||
/// field still forces a decision on the production path.
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -312,10 +265,7 @@ impl Default for ServerConfig {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ObservabilityConfig {
|
pub struct ObservabilityConfig {
|
||||||
pub log_level: String,
|
pub log_level: String,
|
||||||
/// Selects the tracing-subscriber output format. `clap` rejects
|
/// Tracing output format.
|
||||||
/// unrecognized values at parse time (`--log-format jsonl` and
|
|
||||||
/// similar typos surface as an error instead of silently degrading
|
|
||||||
/// to text).
|
|
||||||
pub log_format: LogFormat,
|
pub log_format: LogFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,9 +296,8 @@ impl Default for ObservabilityConfig {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ModelConfig {
|
pub struct ModelConfig {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// Tokenizer source: a local `tokenizer.json` path or a HuggingFace repo
|
/// Local tokenizer.json or HuggingFace repo id; defaults to `id`.
|
||||||
/// id (downloaded on demand). Defaults to `id` when `--tokenizer-path`
|
/// Resolved by [`crate::tokenizer::adapter::load`].
|
||||||
/// is omitted. Resolved by [`crate::tokenizer::adapter::load`].
|
|
||||||
pub tokenizer_path: String,
|
pub tokenizer_path: String,
|
||||||
/// Disable router-generated input IDs for this model; keep routing tokenization.
|
/// Disable router-generated input IDs for this model; keep routing tokenization.
|
||||||
/// Use when workers have rendering defaults or template stops the router cannot see.
|
/// Use when workers have rendering defaults or template stops the router cannot see.
|
||||||
@@ -361,25 +310,15 @@ pub struct ModelConfig {
|
|||||||
pub circuit_breaker: Option<CircuitBreakerConfig>,
|
pub circuit_breaker: Option<CircuitBreakerConfig>,
|
||||||
/// Cache-Aware prefix configuration.
|
/// Cache-Aware prefix configuration.
|
||||||
pub cache_aware: Option<CacheAwareConfig>,
|
pub cache_aware: Option<CacheAwareConfig>,
|
||||||
/// Tuning for the sticky-session policy. `Some` exactly when
|
/// Present only for the sticky policy; the header supplies the request routing key.
|
||||||
/// `policy = "sticky"` (built by [`crate::config::cli::Cli::into_config`]).
|
|
||||||
/// The chat handler reads `sticky.header_name` to populate
|
|
||||||
/// [`crate::policies::SelectionContext::routing_key`].
|
|
||||||
pub sticky: Option<StickyConfig>,
|
pub sticky: Option<StickyConfig>,
|
||||||
/// Session and cache-affinity tuning.
|
/// Session and cache-affinity tuning.
|
||||||
pub affinity: Option<AffinityConfig>,
|
pub affinity: Option<AffinityConfig>,
|
||||||
/// Terms the score-composition policy sums. `Some` exactly when
|
/// Terms for `fused_score` or `score_policy`; defaults to [`DEFAULT_FUSE`].
|
||||||
/// `policy = "fused_score"` or `policy = "score_policy"` (built by
|
|
||||||
/// [`crate::config::cli::Cli::into_config`]), defaulting to
|
|
||||||
/// [`DEFAULT_FUSE`] when `--fuse` is omitted.
|
|
||||||
pub fused: Option<Vec<FusedTerm>>,
|
pub fused: Option<Vec<FusedTerm>>,
|
||||||
/// Hard constraints applied before policy selection.
|
/// Hard constraints applied before policy selection.
|
||||||
pub eligibility: Option<EligibilityConfig>,
|
pub eligibility: Option<EligibilityConfig>,
|
||||||
/// Sampling parameters fixed fleet-wide for this model, and what happens
|
/// Fleet sampling defaults and conflict behavior. See [`SamplingOverrides`].
|
||||||
/// to a request that sends a different value: a 400 before admission, or
|
|
||||||
/// the client value forwarded untouched. Either way the configured value
|
|
||||||
/// is injected when the request omits the field — see
|
|
||||||
/// [`SamplingOverrides`]. Empty (default) preserves today's behavior.
|
|
||||||
pub sampling_overrides: SamplingOverrides,
|
pub sampling_overrides: SamplingOverrides,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,9 +397,7 @@ pub struct CacheAwareConfig {
|
|||||||
pub kv_indexer_endpoint: Option<KvIndexerEndpointConfig>,
|
pub kv_indexer_endpoint: Option<KvIndexerEndpointConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default routing-key header for the sticky policy. The `x-sgl-` prefix
|
/// Default request header for sticky routing.
|
||||||
/// matches the router's other emitted/consumed metadata headers
|
|
||||||
/// (`x-sgl-decode-url`, `x-sgl-router-error-code`).
|
|
||||||
pub const DEFAULT_STICKY_HEADER: &str = "x-sgl-routing-key";
|
pub const DEFAULT_STICKY_HEADER: &str = "x-sgl-routing-key";
|
||||||
|
|
||||||
/// Default request header for session-aware routing.
|
/// Default request header for session-aware routing.
|
||||||
@@ -520,66 +457,23 @@ pub struct AffinityConfig {
|
|||||||
pub cache_candidate_ratio: f64,
|
pub cache_candidate_ratio: f64,
|
||||||
pub cache_candidate_max_workers: usize,
|
pub cache_candidate_max_workers: usize,
|
||||||
pub cache_switch_margin_tokens: u64,
|
pub cache_switch_margin_tokens: u64,
|
||||||
/// Queue gate (`--worker-queue-limit`): a worker whose engine reports at
|
/// Waiting-request limit for cache affinity; `None` disables.
|
||||||
/// least this many *waiting* requests cannot win a selection on cache
|
/// Gates on the engine-published *waiting* count rather than total depth because
|
||||||
/// affinity — the request goes to another worker holding the same
|
/// waiting is the question the request cares about — will it sit behind other work —
|
||||||
/// prefix, or failing that to the least-loaded worker that is not
|
/// while depth proxies it badly (an engine can queue far below its running cap on
|
||||||
/// queueing. `None` disables the gate.
|
/// long-prompt traffic). Fails open without a fresh sample: the router-side
|
||||||
///
|
/// in-flight counter cannot separate running from waiting requests.
|
||||||
/// Gating on the queue rather than on total depth is what makes this
|
/// Counts sum across DP ranks, so scale the limit with `dp_size`.
|
||||||
/// targeted: `num_waiting_reqs` IS the question the request cares about
|
|
||||||
/// — will I sit behind other work before my prefill starts — whereas
|
|
||||||
/// depth only proxies it, and proxies it badly (an engine can queue at
|
|
||||||
/// 7-8 running on long-prompt traffic, far below its running cap, so a
|
|
||||||
/// depth threshold either fires on healthy busy workers or misses the
|
|
||||||
/// workers actually making requests wait).
|
|
||||||
///
|
|
||||||
/// The gate reads the engine-published load sample and fails OPEN on a
|
|
||||||
/// worker with no fresh sample: the router-side in-flight counter cannot
|
|
||||||
/// separate a running request from a waiting one, so there is no honest
|
|
||||||
/// substitute to compare the limit against.
|
|
||||||
///
|
|
||||||
/// Note the firing point scales with `dp_size`: the sample sums `waiting`
|
|
||||||
/// across a worker's DP ranks while a request lands on one of them, so
|
|
||||||
/// scale the limit with `--dp-size` on DP-attention deployments.
|
|
||||||
///
|
|
||||||
/// The companion `saturation_queue_floor` cancels the gate's diversions
|
|
||||||
/// when they have no payoff (nothing in the fleet reads below the
|
|
||||||
/// floor).
|
|
||||||
pub worker_queue_limit: Option<u64>,
|
pub worker_queue_limit: Option<u64>,
|
||||||
/// Saturation pin (`--saturation-queue-floor`): cancels queue-gate
|
/// Keep the least-pressured prefix owner when the queue gate rejects all admitted
|
||||||
/// diversions that have no payoff. When no cache candidate survives
|
/// cache candidates and no fresh fleet queue is below this floor. Unknown queues
|
||||||
/// both `worker_queue_limit` and hard admission, at least one was over
|
/// do not count as idle — the opposite of the gate's fail-open, deliberately: the
|
||||||
/// the limit, AND no worker in the routable fleet has a fresh queue
|
/// pin asks whether a provably better destination exists, and an unknown queue is
|
||||||
/// reading strictly below this floor, the diverted request would wait
|
/// not proof. Requires `floor <= worker_queue_limit`; scale with `dp_size`.
|
||||||
/// wherever it lands — so it pins to the least-pressured prefix owner
|
|
||||||
/// instead of cold-prefilling on a non-owner (which evicts other
|
|
||||||
/// prefixes and manufactures the next round of misses). `None` — the
|
|
||||||
/// default — preserves the pure gate behavior.
|
|
||||||
///
|
|
||||||
/// Polarity note: a worker with no fresh sample does NOT count as idle
|
|
||||||
/// — the opposite of the gate's fail-open, and deliberately so. The
|
|
||||||
/// gate keeps affinity because that is the safe default action; the
|
|
||||||
/// pin asks whether a *provably better* destination exists, and an
|
|
||||||
/// unknown queue is not proof. Both polarities leave the request with
|
|
||||||
/// its prefix owner when the signal is missing.
|
|
||||||
///
|
|
||||||
/// The CLI enforces `floor <= worker_queue_limit` and requires the
|
|
||||||
/// gate; like the limit, scale the floor with `dp_size`.
|
|
||||||
pub saturation_queue_floor: Option<u64>,
|
pub saturation_queue_floor: Option<u64>,
|
||||||
/// Number of random candidates sampled for the min-load fallback
|
/// Min-load fallback sample size; defaults to power-of-two. At least the pool
|
||||||
/// (`--min-load-choices`); the least-pressured of the sample wins.
|
/// size chooses the exact minimum with random ties; 1 draws uniformly without
|
||||||
/// [`DEFAULT_MIN_LOAD_CHOICES`] is the pre-existing power-of-2
|
/// a backup for admission or pressure guards. Separate from cache-owner limits.
|
||||||
/// behavior, so upgrading changes nothing. `k >= pool` skips the
|
|
||||||
/// shuffle and returns the exact minimum, with ties broken randomly
|
|
||||||
/// (an idle fleet ties on every comparison, so a fixed order would pin
|
|
||||||
/// every fallback dispatch to one worker); `k = 1` is a uniform draw
|
|
||||||
/// within the tier, and its sample has no second member, so the
|
|
||||||
/// proposal carries no backup and admission loses its backup-admission
|
|
||||||
/// and pressure-guard paths. The
|
|
||||||
/// `--cache-candidate-*` knobs bound the cache-affinity OWNER candidate
|
|
||||||
/// set; this bounds the min-load FALLBACK sample used when no owner is
|
|
||||||
/// usable.
|
|
||||||
pub min_load_choices: usize,
|
pub min_load_choices: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,18 +504,13 @@ impl Default for AffinityConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-model sticky-session tuning. Built from the `--routing-key-header`
|
/// Sticky routing settings; the CLI validates the header name and positive durations.
|
||||||
/// / `--sticky-*` flags by [`crate::config::cli::Cli::into_config`], which
|
|
||||||
/// also validates that `header_name` parses as an HTTP header name.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StickyConfig {
|
pub struct StickyConfig {
|
||||||
/// Request header carrying the routing key. Validated to parse as a
|
/// Request header carrying the routing key. Validated to parse as a
|
||||||
/// `http::HeaderName` at config-build time.
|
/// `http::HeaderName` at config-build time.
|
||||||
pub header_name: String,
|
pub header_name: String,
|
||||||
/// Policy used to pick a worker when a request has no routing key, and
|
/// Fallback for new or missing routing keys.
|
||||||
/// to pick the initial worker when a new key is first seen. One of
|
|
||||||
/// `round_robin` / `random` / `power_of_two` / `load_based` — the
|
|
||||||
/// dependency-free policies the factory can build standalone.
|
|
||||||
pub fallback_policy: StickyFallbackKind,
|
pub fallback_policy: StickyFallbackKind,
|
||||||
/// Evict an assignment after it has been idle (unreferenced) this many
|
/// Evict an assignment after it has been idle (unreferenced) this many
|
||||||
/// seconds. Bounds the map against unbounded routing-key cardinality.
|
/// seconds. Bounds the map against unbounded routing-key cardinality.
|
||||||
@@ -650,10 +539,7 @@ impl Default for StickyConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CircuitBreakerConfig {
|
pub struct CircuitBreakerConfig {
|
||||||
/// Consecutive failures required before the breaker opens. Encoded
|
/// Consecutive failures before opening the breaker; zero is invalid.
|
||||||
/// as `NonZeroU32` so `--cb-threshold 0` (which would open the
|
|
||||||
/// breaker before any failure) is rejected at CLI-parse time rather
|
|
||||||
/// than silently behaving as "always open".
|
|
||||||
pub threshold: NonZeroU32,
|
pub threshold: NonZeroU32,
|
||||||
pub cool_down_secs: u64,
|
pub cool_down_secs: u64,
|
||||||
}
|
}
|
||||||
@@ -670,43 +556,15 @@ pub enum DiscoveryBackend {
|
|||||||
K8s(K8sDiscoveryConfig),
|
K8s(K8sDiscoveryConfig),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed list of worker URLs. Each URL is registered once at startup;
|
/// Workers registered at startup. Roles, models, and bootstrap ports come from
|
||||||
/// `mode`, `model_ids`, and `bootstrap_port` are resolved per-worker
|
/// `/server_info`; topology changes require a restart.
|
||||||
/// from `/server_info` (see [`crate::workers::introspect`]).
|
|
||||||
///
|
|
||||||
/// No file watcher, no hot-reload: topology change requires a restart.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StaticUrlsDiscoveryConfig {
|
pub struct StaticUrlsDiscoveryConfig {
|
||||||
pub urls: Vec<String>,
|
pub urls: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for the Kubernetes `EndpointSlice` discovery backend.
|
/// Kubernetes EndpointSlice discovery. Selectors classify slices; worker roles
|
||||||
/// Built from the `--service-discovery*` / `--selector` / `--prefill-selector`
|
/// and bootstrap ports come from `/server_info` introspection.
|
||||||
/// / `--decode-selector` flags by [`crate::config::cli::Cli::build_discovery`].
|
|
||||||
///
|
|
||||||
/// Two operating modes, distinguished by which selector flags are set:
|
|
||||||
///
|
|
||||||
/// 1. **Plain** — all matched workers share the same role:
|
|
||||||
/// `--service-discovery-namespace default --selector app=sglang`
|
|
||||||
///
|
|
||||||
/// 2. **PD disaggregation** — prefill and decode workers are separated by
|
|
||||||
/// different selectors:
|
|
||||||
/// `--service-discovery-namespace default
|
|
||||||
/// --prefill-selector app=sglang,role=prefill
|
|
||||||
/// --decode-selector app=sglang,role=decode`
|
|
||||||
///
|
|
||||||
/// In PD mode, the selectors drive **slice-classification** (which
|
|
||||||
/// EndpointSlices feed the prefill pool vs the decode pool). The actual
|
|
||||||
/// `WorkerMode` and `bootstrap_port` for each worker are filled in by
|
|
||||||
/// the worker manager from each worker's `/server_info` introspection,
|
|
||||||
/// so PD works without any pod-level annotations — see
|
|
||||||
/// [`crate::workers::introspect`] for the `disaggregation_mode` and
|
|
||||||
/// `disaggregation_bootstrap_port` extraction.
|
|
||||||
///
|
|
||||||
/// [`resolve_mode`] validates the selector flags and produces the
|
|
||||||
/// resolved [`K8sDiscoveryMode`] once, at construction in
|
|
||||||
/// [`crate::config::cli::Cli::build_discovery`] — so an invalid selector
|
|
||||||
/// combination is unrepresentable here.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct K8sDiscoveryConfig {
|
pub struct K8sDiscoveryConfig {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
@@ -714,14 +572,8 @@ pub struct K8sDiscoveryConfig {
|
|||||||
pub mode: K8sDiscoveryMode,
|
pub mode: K8sDiscoveryMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved discovery mode, produced by [`resolve_mode`] from the CLI
|
/// Validated selector mode. Plain selectors run server-side; PD selectors
|
||||||
/// selector flags and stored on [`K8sDiscoveryConfig`].
|
/// classify EndpointSlices client-side.
|
||||||
///
|
|
||||||
/// The discovery backend uses this to:
|
|
||||||
/// * pick the server-side `LIST` label selector (Plain: the single selector;
|
|
||||||
/// PD: empty, with classification done client-side per slice), and
|
|
||||||
/// * assign each `EndpointSlice` a [`crate::discovery::WorkerMode`] in
|
|
||||||
/// `extract_workers`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum K8sDiscoveryMode {
|
pub enum K8sDiscoveryMode {
|
||||||
/// One global label selector; every matched EndpointSlice becomes a
|
/// One global label selector; every matched EndpointSlice becomes a
|
||||||
@@ -774,52 +626,31 @@ pub enum ConfigError {
|
|||||||
IdenticalPdSelectors,
|
IdenticalPdSelectors,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when `selector` has zero non-empty terms after
|
/// An empty selector matches every slice, which is invalid for a PD role.
|
||||||
/// trimming and splitting on `,`. `labels_match_selector` then returns
|
|
||||||
/// `true` for every label set, which is the "matches everything"
|
|
||||||
/// degenerate case PD mode must reject.
|
|
||||||
fn is_selector_empty(selector: &str) -> bool {
|
fn is_selector_empty(selector: &str) -> bool {
|
||||||
selector.split(',').all(|t| t.trim().is_empty())
|
selector.split(',').all(|t| t.trim().is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonicalize a comma-separated equality selector to a sorted list of
|
/// Normalize validated equality terms for comparison, matching runtime whitespace
|
||||||
/// parsed `(key, value)` tuples. Comparison happens at the parsed-term
|
/// and `=`/`==` handling. Term order does not affect matching.
|
||||||
/// level — *not* the raw string level — because `labels_match_selector`
|
fn canonical_selector(selector: &str) -> Vec<(&str, &str)> {
|
||||||
/// already strips whitespace and treats `key=value` and `key==value` as
|
let mut terms: Vec<_> = selector
|
||||||
/// the same equality test. Comparing raw strings would let
|
|
||||||
/// `"app=sglang"` vs `"app==sglang"` (and `"app = sglang"` vs
|
|
||||||
/// `"app=sglang"`) past the identical-selector check, even though
|
|
||||||
/// `classify_mode` would treat them identically at runtime — exactly
|
|
||||||
/// the silent decode-pool-empty failure mode this check exists to
|
|
||||||
/// prevent.
|
|
||||||
///
|
|
||||||
/// Returns an empty `Vec` for selectors with no parseable terms
|
|
||||||
/// (whitespace-only, comma-only, or any term that doesn't match the
|
|
||||||
/// `key=value` / `key==value` grammar). Callers must run
|
|
||||||
/// [`is_equality_selector`] before this to surface malformed
|
|
||||||
/// selectors as `UnsupportedSelectorGrammar`.
|
|
||||||
fn canonical_selector(selector: &str) -> Vec<(String, String)> {
|
|
||||||
let mut terms: Vec<(String, String)> = selector
|
|
||||||
.split(',')
|
.split(',')
|
||||||
.filter_map(|raw| {
|
.filter_map(|raw| {
|
||||||
let term = raw.trim();
|
let term = raw.trim();
|
||||||
if term.is_empty() {
|
if term.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Mirror `labels_match_selector`: prefer the `==` alias so a
|
// Prefer `==` so its second equals sign does not become part of the value.
|
||||||
// term like `key==value` parses to `(key, value)` instead of
|
|
||||||
// `(key, =value)`.
|
|
||||||
let (k, v) = term.split_once("==").or_else(|| term.split_once('='))?;
|
let (k, v) = term.split_once("==").or_else(|| term.split_once('='))?;
|
||||||
Some((k.trim().to_string(), v.trim().to_string()))
|
Some((k.trim(), v.trim()))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
terms.sort();
|
terms.sort_unstable();
|
||||||
terms
|
terms
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when `selector` parses as a comma-separated equality
|
/// Check the equality-only grammar supported by client-side PD matching.
|
||||||
/// selector — every term has the shape `key=value` or `key==value`.
|
|
||||||
/// See [`ConfigError::UnsupportedSelectorGrammar`] for rationale.
|
|
||||||
fn is_equality_selector(selector: &str) -> bool {
|
fn is_equality_selector(selector: &str) -> bool {
|
||||||
for term in selector.split(',') {
|
for term in selector.split(',') {
|
||||||
let term = term.trim();
|
let term = term.trim();
|
||||||
@@ -835,9 +666,7 @@ fn is_equality_selector(selector: &str) -> bool {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some((k, _value)) = term.split_once('=') {
|
if let Some((k, _value)) = term.split_once('=') {
|
||||||
// Reject `!=` (rendered as `key!` + `=value` by split_once).
|
// Reject `!=`; empty label values are valid.
|
||||||
// Empty value is legal in K8s — `label_selector = "tier="`
|
|
||||||
// matches pods with `tier=""` — so we don't constrain it.
|
|
||||||
if k.trim().is_empty() || k.trim().ends_with('!') {
|
if k.trim().is_empty() || k.trim().ends_with('!') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -849,10 +678,7 @@ fn is_equality_selector(selector: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the selector combination and return the resolved
|
/// Resolve and validate the plain or prefill/decode selector combination.
|
||||||
/// [`K8sDiscoveryMode`]. Called once at construction by
|
|
||||||
/// [`crate::config::cli::Cli::build_discovery`], so an invalid
|
|
||||||
/// combination can never be stored on a [`K8sDiscoveryConfig`].
|
|
||||||
pub fn resolve_mode(
|
pub fn resolve_mode(
|
||||||
label_selector: Option<&str>,
|
label_selector: Option<&str>,
|
||||||
prefill_selector: Option<&str>,
|
prefill_selector: Option<&str>,
|
||||||
@@ -860,57 +686,30 @@ pub fn resolve_mode(
|
|||||||
) -> Result<K8sDiscoveryMode, ConfigError> {
|
) -> Result<K8sDiscoveryMode, ConfigError> {
|
||||||
match (label_selector, prefill_selector, decode_selector) {
|
match (label_selector, prefill_selector, decode_selector) {
|
||||||
(Some(label), None, None) => {
|
(Some(label), None, None) => {
|
||||||
// Plain mode pushes `label` to the K8s API as the
|
// Plain selectors run on the Kubernetes API, which supports the full grammar.
|
||||||
// server-side `labelSelector` of the EndpointSlice
|
// PD selectors are checked client-side and only support equality.
|
||||||
// watcher (`watcher::Config::default().labels(&label)`
|
|
||||||
// in `discovery::k8s::spawn`). K8s itself parses the
|
|
||||||
// full label-selector grammar — equality, set-based
|
|
||||||
// (`in` / `notin`), presence (`key` / `!key`), and
|
|
||||||
// `!=` — and rejects malformed selectors at
|
|
||||||
// watch-start time. So we don't grammar-check `label`
|
|
||||||
// here and let the K8s API be the syntax authority. PD
|
|
||||||
// mode, in contrast, evaluates selectors client-side via
|
|
||||||
// `labels_match_selector` which only understands
|
|
||||||
// equality — so PD selectors are still grammar-checked
|
|
||||||
// below.
|
|
||||||
Ok(K8sDiscoveryMode::Plain {
|
Ok(K8sDiscoveryMode::Plain {
|
||||||
label_selector: label.to_string(),
|
label_selector: label.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
(None, Some(prefill), Some(decode)) => {
|
(None, Some(prefill), Some(decode)) => {
|
||||||
// Both selectors validated individually so the operator
|
// Validate both grammars before checking for empty or identical selectors.
|
||||||
// sees which one is malformed. WorkerMode + bootstrap_port
|
let selectors = [("prefill", prefill), ("decode", decode)];
|
||||||
// for each prefill pod are filled in by the worker
|
for (selector, value) in selectors {
|
||||||
// manager from each worker's `/server_info` — these
|
if !is_equality_selector(value) {
|
||||||
// selectors only drive client-side classification per
|
return Err(ConfigError::UnsupportedSelectorGrammar {
|
||||||
// EndpointSlice (see `classify_mode` in discovery/k8s.rs).
|
selector,
|
||||||
if !is_equality_selector(prefill) {
|
value: value.to_string(),
|
||||||
return Err(ConfigError::UnsupportedSelectorGrammar {
|
});
|
||||||
selector: "prefill",
|
}
|
||||||
value: prefill.to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if !is_equality_selector(decode) {
|
// Empty PD selectors match everything and starve the opposite role.
|
||||||
return Err(ConfigError::UnsupportedSelectorGrammar {
|
for (selector, value) in selectors {
|
||||||
selector: "decode",
|
if is_selector_empty(value) {
|
||||||
value: decode.to_string(),
|
return Err(ConfigError::EmptyPdSelector { selector });
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
// Empty PD selector matches every EndpointSlice at
|
// Prefill wins when both selectors match, leaving decode empty.
|
||||||
// runtime; combined with classify_mode's prefill-first
|
|
||||||
// ordering, an empty selector would silently funnel all
|
|
||||||
// workers into one role. Reject up front.
|
|
||||||
if is_selector_empty(prefill) {
|
|
||||||
return Err(ConfigError::EmptyPdSelector {
|
|
||||||
selector: "prefill",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if is_selector_empty(decode) {
|
|
||||||
return Err(ConfigError::EmptyPdSelector { selector: "decode" });
|
|
||||||
}
|
|
||||||
// Identical selectors degrade the same way as an empty
|
|
||||||
// one: every slice matches both, prefill wins, decode
|
|
||||||
// stays empty.
|
|
||||||
if canonical_selector(prefill) == canonical_selector(decode) {
|
if canonical_selector(prefill) == canonical_selector(decode) {
|
||||||
return Err(ConfigError::IdenticalPdSelectors);
|
return Err(ConfigError::IdenticalPdSelectors);
|
||||||
}
|
}
|
||||||
@@ -931,10 +730,6 @@ mod k8s_discovery_config_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_constructs_pd_disaggregation_from_prefill_and_decode_selectors() {
|
fn mode_constructs_pd_disaggregation_from_prefill_and_decode_selectors() {
|
||||||
// K8s PD now works without per-pod annotations: each worker's
|
|
||||||
// `/server_info` carries `disaggregation_bootstrap_port`, and the
|
|
||||||
// worker manager applies it post-discovery. The K8s config layer's
|
|
||||||
// job is just to validate the selector combination.
|
|
||||||
let m = resolve_mode(None, Some("app=sglang,role=p"), Some("app=sglang,role=d"))
|
let m = resolve_mode(None, Some("app=sglang,role=p"), Some("app=sglang,role=d"))
|
||||||
.expect("PD mode is now valid");
|
.expect("PD mode is now valid");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -948,9 +743,6 @@ mod k8s_discovery_config_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_set_based_prefill_selector() {
|
fn mode_pd_rejects_set_based_prefill_selector() {
|
||||||
// Both PD selectors get the same equality-only grammar check as
|
|
||||||
// the plain label_selector. A set-based prefill selector would
|
|
||||||
// silently match zero pods at runtime → fail-fast at load.
|
|
||||||
let err =
|
let err =
|
||||||
resolve_mode(None, Some("app in (sglang, vllm)"), Some("app=sglang")).unwrap_err();
|
resolve_mode(None, Some("app in (sglang, vllm)"), Some("app=sglang")).unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -992,12 +784,8 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plain mode pushes its selector to the K8s API server-side
|
/// Plain selectors run on the Kubernetes API, which supports the full grammar.
|
||||||
/// (`watcher::Config::default().labels(&selector)` in
|
/// PD selectors are checked client-side and only support equality.
|
||||||
/// `discovery::k8s::spawn`), so the full K8s label-selector grammar
|
|
||||||
/// — including set-based operators like `app in (a,b)` — is
|
|
||||||
/// supported and must not be grammar-checked at startup. PD mode
|
|
||||||
/// (checked client-side) is the opposite; see the PD tests below.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_accepts_set_based_selector_in_plain_mode() {
|
fn mode_accepts_set_based_selector_in_plain_mode() {
|
||||||
let m = resolve_mode(Some("app in (sglang,sglang-small)"), None, None)
|
let m = resolve_mode(Some("app in (sglang,sglang-small)"), None, None)
|
||||||
@@ -1010,9 +798,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `notin`, presence (`key`), absence (`!key`), and inequality (`!=`)
|
|
||||||
/// are all valid K8s server-side selector grammar — plain mode must
|
|
||||||
/// pass them through.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_accepts_other_set_based_forms_in_plain_mode() {
|
fn mode_accepts_other_set_based_forms_in_plain_mode() {
|
||||||
for raw in [
|
for raw in [
|
||||||
@@ -1033,15 +818,6 @@ mod k8s_discovery_config_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PD mode evaluates selectors *client-side* via
|
|
||||||
/// `labels_match_selector`, which only handles equality. A set-based
|
|
||||||
/// PD selector would silently match zero pods → fail-fast at load.
|
|
||||||
/// Pins the plain-server-side / PD-client-side asymmetry: relaxing
|
|
||||||
/// the grammar check for plain (see `mode_accepts_set_based_*`
|
|
||||||
/// above) must not accidentally relax it for PD selectors. Uses
|
|
||||||
/// `notin` so this test covers a different set-based form than
|
|
||||||
/// `mode_pd_rejects_set_based_prefill_selector` (which uses `in`)
|
|
||||||
/// — both must keep failing.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_notin_prefill_selector() {
|
fn mode_pd_rejects_notin_prefill_selector() {
|
||||||
let err =
|
let err =
|
||||||
@@ -1101,9 +877,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Empty plain `label_selector` is valid — matches every
|
|
||||||
/// EndpointSlice in the namespace (documented K8s behavior; the
|
|
||||||
/// operator opts in by setting plain mode at all).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_accepts_empty_plain_label_selector() {
|
fn mode_accepts_empty_plain_label_selector() {
|
||||||
let m = resolve_mode(Some(""), None, None).unwrap();
|
let m = resolve_mode(Some(""), None, None).unwrap();
|
||||||
@@ -1115,12 +888,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PD mode is the *opposite* of plain: an empty selector would match
|
|
||||||
/// every EndpointSlice, and since `classify_mode` checks prefill
|
|
||||||
/// before decode, an empty `prefill_selector` would classify
|
|
||||||
/// everything as Prefill — decode pool stays empty and the resolver
|
|
||||||
/// surfaces the wrong `no_decode_workers_available` error. Fail-fast
|
|
||||||
/// at config load.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_empty_prefill_selector() {
|
fn mode_pd_rejects_empty_prefill_selector() {
|
||||||
let err = resolve_mode(None, Some(""), Some("role=decode")).unwrap_err();
|
let err = resolve_mode(None, Some(""), Some("role=decode")).unwrap_err();
|
||||||
@@ -1144,9 +911,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whitespace-only / comma-only PD selector parses to zero terms in
|
|
||||||
/// `labels_match_selector` and matches every slice at runtime — same
|
|
||||||
/// failure mode as a literal empty string.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_whitespace_only_prefill_selector() {
|
fn mode_pd_rejects_whitespace_only_prefill_selector() {
|
||||||
let err = resolve_mode(None, Some(" , "), Some("role=decode")).unwrap_err();
|
let err = resolve_mode(None, Some(" , "), Some("role=decode")).unwrap_err();
|
||||||
@@ -1161,9 +925,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identical prefill and decode selectors degrade silently: every
|
|
||||||
/// slice matches both, but `classify_mode` returns `Prefill` first,
|
|
||||||
/// so the decode pool stays empty.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_identical_prefill_and_decode_selectors() {
|
fn mode_pd_rejects_identical_prefill_and_decode_selectors() {
|
||||||
let err = resolve_mode(None, Some("app=sglang"), Some("app=sglang")).unwrap_err();
|
let err = resolve_mode(None, Some("app=sglang"), Some("app=sglang")).unwrap_err();
|
||||||
@@ -1184,14 +945,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `labels_match_selector` accepts both `key=value` and `key==value`
|
|
||||||
/// for equality and parses them to the same `(key, value)` tuple.
|
|
||||||
/// Two selectors that differ only in this alias choice are runtime-
|
|
||||||
/// equivalent — they'd match the same EndpointSlices, then
|
|
||||||
/// `classify_mode`'s prefill-first ordering would funnel every slice
|
|
||||||
/// into Prefill, leaving decode empty. The check must canonicalize
|
|
||||||
/// at the term level (parsed `(key, value)` tuples), not the raw
|
|
||||||
/// string level.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_identical_selectors_under_eq_alias() {
|
fn mode_pd_rejects_identical_selectors_under_eq_alias() {
|
||||||
let err = resolve_mode(None, Some("app=sglang"), Some("app==sglang")).unwrap_err();
|
let err = resolve_mode(None, Some("app=sglang"), Some("app==sglang")).unwrap_err();
|
||||||
@@ -1201,11 +954,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inner whitespace inside a term (`"app = sglang"`) is the same
|
|
||||||
/// label as no whitespace (`"app=sglang"`) — the runtime
|
|
||||||
/// `labels_match_selector` trims key and value independently
|
|
||||||
/// (see `key.trim()` / `expected.trim()` in `k8s.rs`). Canonical
|
|
||||||
/// form must agree.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_identical_selectors_under_inner_whitespace() {
|
fn mode_pd_rejects_identical_selectors_under_inner_whitespace() {
|
||||||
let err = resolve_mode(None, Some("app=sglang"), Some("app = sglang")).unwrap_err();
|
let err = resolve_mode(None, Some("app=sglang"), Some("app = sglang")).unwrap_err();
|
||||||
@@ -1215,11 +963,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Term order doesn't matter for label matching, so `"a=1,b=2"` and
|
|
||||||
/// `"b=2,a=1"` must be treated as identical. (Implied by the sort
|
|
||||||
/// in `canonical_selector`, but pinned explicitly so a future
|
|
||||||
/// "preserve user order for diagnostics" refactor can't silently
|
|
||||||
/// reintroduce the silent-failure bug.)
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_rejects_identical_selectors_under_term_order_permutation() {
|
fn mode_pd_rejects_identical_selectors_under_term_order_permutation() {
|
||||||
let err =
|
let err =
|
||||||
@@ -1230,9 +973,6 @@ mod k8s_discovery_config_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanity: two selectors that genuinely differ at the term level
|
|
||||||
/// must still pass validation — the canonicalizer must not be so
|
|
||||||
/// aggressive that it false-positives on legitimate PD configs.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_pd_accepts_truly_distinct_selectors() {
|
fn mode_pd_accepts_truly_distinct_selectors() {
|
||||||
let m = resolve_mode(
|
let m = resolve_mode(
|
||||||
|
|||||||
Reference in New Issue
Block a user