[sgl-router] refactor - move policy-required states under src/state (#40272)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kan Wu
2026-09-20 16:49:23 -07:00
committed by GitHub
co-authored by Claude Fable 5.1
parent acd20a516e
commit 4a9dc5c4af
78 changed files with 787 additions and 655 deletions
@@ -35,7 +35,7 @@ use std::thread;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
use sgl_router::policies::kv_events::tree::{HashTree, KvWorkerId}; use sgl_router::state::kv_events::tree::{HashTree, KvWorkerId};
fn build_tree(num_workers: usize, blocks_per_worker: usize, seed: u64) -> HashTree { fn build_tree(num_workers: usize, blocks_per_worker: usize, seed: u64) -> HashTree {
let tree = HashTree::new(); let tree = HashTree::new();
+7 -7
View File
@@ -10,10 +10,10 @@ use std::num::NonZeroU32;
use crate::config::sampling::{parse_sampling_overrides, ConflictPolicy}; use crate::config::sampling::{parse_sampling_overrides, ConflictPolicy};
use crate::config::{ use crate::config::{
default_cb_cool_down, default_host, default_port, default_proxy_request_timeout_secs, default_cb_cool_down, default_host, default_port, default_proxy_request_timeout_secs,
default_shutdown_drain_secs, default_stale_request_timeout_secs, resolve_mode, default_shutdown_drain_secs, default_stale_request_timeout_secs, resolve_mode, AffinityConfig,
ActiveLoadConfig, AffinityConfig, AffinityMode, CacheAwareConfig, CachePrefixProvider, AffinityMode, CacheAwareConfig, CachePrefixProvider, CircuitBreakerConfig, Config,
CircuitBreakerConfig, Config, DecodePolicyKind, DiscoveryBackend, EligibilityConfig, DecodePolicyKind, DiscoveryBackend, EligibilityConfig, FilterKind, FusedTerm,
FilterKind, FusedTerm, K8sDiscoveryConfig, KvIndexerEndpointConfig, LogFormat, ModelConfig, InflightLoadConfig, K8sDiscoveryConfig, KvIndexerEndpointConfig, LogFormat, ModelConfig,
ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode, ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode,
StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind, DEFAULT_FUSE, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind, DEFAULT_FUSE,
}; };
@@ -390,7 +390,7 @@ impl Cli {
proxy: ProxyConfig { proxy: ProxyConfig {
request_timeout_secs: self.server.request_timeout_secs, request_timeout_secs: self.server.request_timeout_secs,
}, },
active_load: ActiveLoadConfig { router_inflight_load: InflightLoadConfig {
stale_request_timeout_secs: self.server.stale_request_timeout_secs, stale_request_timeout_secs: self.server.stale_request_timeout_secs,
}, },
}; };
@@ -906,7 +906,7 @@ mod tests {
assert_eq!(c.model.policy, PolicyKind::RoundRobin); assert_eq!(c.model.policy, PolicyKind::RoundRobin);
assert_eq!(c.model.id, "qwen3-0.6b"); assert_eq!(c.model.id, "qwen3-0.6b");
assert_eq!(c.proxy.request_timeout_secs, 300); assert_eq!(c.proxy.request_timeout_secs, 300);
assert_eq!(c.active_load.stale_request_timeout_secs, 600); assert_eq!(c.router_inflight_load.stale_request_timeout_secs, 600);
assert_eq!(c.server.shutdown_drain_secs, 30); assert_eq!(c.server.shutdown_drain_secs, 30);
} }
@@ -1473,7 +1473,7 @@ mod tests {
])) ]))
.unwrap(); .unwrap();
assert_eq!(c.proxy.request_timeout_secs, 120); assert_eq!(c.proxy.request_timeout_secs, 120);
assert_eq!(c.active_load.stale_request_timeout_secs, 240); assert_eq!(c.router_inflight_load.stale_request_timeout_secs, 240);
} }
#[test] #[test]
+1 -1
View File
@@ -237,7 +237,7 @@ mod tests {
urls: urls.iter().map(|s| s.to_string()).collect(), urls: urls.iter().map(|s| s.to_string()).collect(),
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
+3 -3
View File
@@ -11,7 +11,7 @@ pub struct Config {
/// Discovery mode resolved from CLI options; static URLs are checked by [`Config::validate`]. /// Discovery mode resolved from CLI options; static URLs are checked by [`Config::validate`].
pub discovery: DiscoveryBackend, pub discovery: DiscoveryBackend,
pub proxy: ProxyConfig, pub proxy: ProxyConfig,
pub active_load: ActiveLoadConfig, pub router_inflight_load: InflightLoadConfig,
} }
/// Outbound request timeout settings. /// Outbound request timeout settings.
@@ -35,7 +35,7 @@ impl Default for ProxyConfig {
/// Request-tracking timeout; defaults above the proxy timeout. /// Request-tracking timeout; defaults above the proxy timeout.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct ActiveLoadConfig { pub struct InflightLoadConfig {
/// Maximum request-entry lifetime before cancellation with 504 `stale_request_expired`. /// Maximum request-entry lifetime before cancellation with 504 `stale_request_expired`.
pub stale_request_timeout_secs: u64, pub stale_request_timeout_secs: u64,
} }
@@ -44,7 +44,7 @@ pub fn default_stale_request_timeout_secs() -> u64 {
600 600
} }
impl Default for ActiveLoadConfig { impl Default for InflightLoadConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
stale_request_timeout_secs: default_stale_request_timeout_secs(), stale_request_timeout_secs: default_stale_request_timeout_secs(),
+1 -1
View File
@@ -1022,7 +1022,7 @@ mod tests {
} }
/// Pod is replaced (same IP, different UID) — router must see this as /// Pod is replaced (same IP, different UID) — router must see this as
/// a Removed+Added cycle so the new pod gets fresh CB/active_load /// a Removed+Added cycle so the new pod gets fresh CB/router_inflight_load
/// state. Without UID-keyed WorkerIds, two consecutive /// state. Without UID-keyed WorkerIds, two consecutive
/// `process_events` snapshots would dedup by `addr:port` and the /// `process_events` snapshots would dedup by `addr:port` and the
/// new pod would inherit the dead pod's state. /// new pod would inherit the dead pod's state.
+1
View File
@@ -14,5 +14,6 @@ pub mod health;
pub mod policies; pub mod policies;
pub mod proxy; pub mod proxy;
pub mod server; pub mod server;
pub mod state;
pub mod tokenizer; pub mod tokenizer;
pub mod workers; pub mod workers;
+20 -13
View File
@@ -8,14 +8,17 @@ use sgl_router::{
config::{CachePrefixProvider, Cli, Config, KvIndexerEndpointConfig, LogFormat, PolicyKind}, config::{CachePrefixProvider, Cli, Config, KvIndexerEndpointConfig, LogFormat, PolicyKind},
discovery::spawn_discovery, discovery::spawn_discovery,
policies::{ policies::{
active_load::{spawn_janitor, ActiveLoadRegistry, JanitorHandle, SystemTimeClock}, factory::build_registry as build_policy_registry, prefix_provider::RadixTreePrefixProvider,
factory::build_registry as build_policy_registry,
kv_events::{BlockSizeOracle, KvEventIndex},
prefix_provider::RadixTreePrefixProvider,
PolicyRegistry, PolicyRegistry,
}, },
proxy::Proxy, proxy::Proxy,
server::{app::build_router, app_context::AppContext, shutdown::drain_for_termination}, server::{app::build_router, app_context::AppContext, shutdown::drain_for_termination},
state::{
kv_events::{BlockSizeOracle, KvEventIndex},
load_monitor::router_inflight_load::{
spawn_janitor, JanitorHandle, RouterInflightLoadRegistry, SystemTimeClock,
},
},
tokenizer::TokenizerRegistry, tokenizer::TokenizerRegistry,
workers::{manager, WorkerRegistry}, workers::{manager, WorkerRegistry},
}; };
@@ -228,10 +231,14 @@ fn start_engine_state_monitor(use_external_indexer: bool) -> Arc<KvEventIndex> {
} }
} }
fn start_local_inflight_tracker(config: &Config) -> (Arc<ActiveLoadRegistry>, JanitorHandle) { fn start_local_inflight_tracker(
let timeout_secs = config.active_load.stale_request_timeout_secs; config: &Config,
let local_inflight_requests = ) -> (Arc<RouterInflightLoadRegistry>, JanitorHandle) {
ActiveLoadRegistry::new(Arc::new(SystemTimeClock), Duration::from_secs(timeout_secs)); let timeout_secs = config.router_inflight_load.stale_request_timeout_secs;
let local_inflight_requests = RouterInflightLoadRegistry::new(
Arc::new(SystemTimeClock),
Duration::from_secs(timeout_secs),
);
// Reap stale requests at one tenth of their timeout, bounded to 160 seconds. // Reap stale requests at one tenth of their timeout, bounded to 160 seconds.
let sweep_interval = Duration::from_secs((timeout_secs / 10).clamp(1, 60)); let sweep_interval = Duration::from_secs((timeout_secs / 10).clamp(1, 60));
let inflight_cleanup = spawn_janitor(Arc::clone(&local_inflight_requests), sweep_interval); let inflight_cleanup = spawn_janitor(Arc::clone(&local_inflight_requests), sweep_interval);
@@ -242,7 +249,7 @@ async fn start_worker_discovery_and_manager(
config: &Config, config: &Config,
worker_registry: &Arc<WorkerRegistry>, worker_registry: &Arc<WorkerRegistry>,
engine_state: &Arc<KvEventIndex>, engine_state: &Arc<KvEventIndex>,
local_inflight_requests: &Arc<ActiveLoadRegistry>, local_inflight_requests: &Arc<RouterInflightLoadRegistry>,
) -> Result<(JoinHandle<()>, JoinHandle<()>)> { ) -> Result<(JoinHandle<()>, JoinHandle<()>)> {
let (worker_events, discovery_handle) = let (worker_events, discovery_handle) =
spawn_discovery(config).await.context("spawn discovery")?; spawn_discovery(config).await.context("spawn discovery")?;
@@ -262,7 +269,7 @@ fn build_app_context(
tokenizers: Arc<TokenizerRegistry>, tokenizers: Arc<TokenizerRegistry>,
worker_registry: Arc<WorkerRegistry>, worker_registry: Arc<WorkerRegistry>,
routing_policies: Arc<PolicyRegistry>, routing_policies: Arc<PolicyRegistry>,
local_inflight_requests: Arc<ActiveLoadRegistry>, local_inflight_requests: Arc<RouterInflightLoadRegistry>,
engine_state: &KvEventIndex, engine_state: &KvEventIndex,
external_kv_indexer_client: Option<Arc<dyn PrefixIndex>>, external_kv_indexer_client: Option<Arc<dyn PrefixIndex>>,
) -> Result<Arc<AppContext>> { ) -> Result<Arc<AppContext>> {
@@ -272,7 +279,7 @@ fn build_app_context(
.context("build proxy client")?, .context("build proxy client")?,
); );
let mut app_context = AppContext::with_active_load( let mut app_context = AppContext::with_router_inflight_load(
config.clone(), config.clone(),
tokenizers, tokenizers,
proxy, proxy,
@@ -289,7 +296,7 @@ fn build_app_context(
.is_some_and(|cache| cache.prefix_provider == CachePrefixProvider::RadixTree)) .is_some_and(|cache| cache.prefix_provider == CachePrefixProvider::RadixTree))
.then(|| RadixTreePrefixProvider::new(engine_state.tree(), Arc::clone(&block_size_oracle))); .then(|| RadixTreePrefixProvider::new(engine_state.tree(), Arc::clone(&block_size_oracle)));
app_context.block_size_oracle = block_size_oracle; app_context.block_size_oracle = block_size_oracle;
app_context.engine_load = engine_state.engine_load(); app_context.engine_reported_load = engine_state.engine_reported_load();
app_context.kv_metrics = engine_state.metrics_source(); app_context.kv_metrics = engine_state.metrics_source();
Ok(Arc::new(app_context)) Ok(Arc::new(app_context))
} }
@@ -350,7 +357,7 @@ async fn report_drain_progress(
tracing::$level!( tracing::$level!(
elapsed_secs = $elapsed, elapsed_secs = $elapsed,
inflight_http = app_context.inflight_http.count(), inflight_http = app_context.inflight_http.count(),
inflight_proxied = app_context.active_load.inflight_count(), inflight_proxied = app_context.router_inflight_load.inflight_count(),
"still draining in-flight requests; this phase is unbounded and ends at \ "still draining in-flight requests; this phase is unbounded and ends at \
SIGKILL when terminationGracePeriodSeconds expires", SIGKILL when terminationGracePeriodSeconds expires",
) )
@@ -8,7 +8,7 @@
//! Router-local load. //! Router-local load.
//! //!
//! The optional queue gate (`--worker-queue-limit`) is the one criterion here //! The optional queue gate (`--worker-queue-limit`) is the one criterion here
//! that reads [`EngineWorkerLoad::num_waiting_reqs`] rather than the native //! that reads [`EngineReportedWorkerLoad::num_waiting_reqs`] rather than the native
//! monitor fields: a worker already making requests wait cannot win on cache //! monitor fields: a worker already making requests wait cannot win on cache
//! affinity. It fails open on a missing sample — see [`queue_gate_admits`]. //! affinity. It fails open on a missing sample — see [`queue_gate_admits`].
//! The optional saturation floor (`--saturation-queue-floor`) cancels a //! The optional saturation floor (`--saturation-queue-floor`) cancels a
@@ -17,9 +17,11 @@
//! fleet reads below the floor, the request pins to the least-pressured //! fleet reads below the floor, the request pins to the least-pressured
//! prefix owner instead of cold-prefilling on a non-owner. //! prefix owner instead of cold-prefilling on a non-owner.
use crate::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad, NativeCacheWorkerLoad};
use crate::policies::power_of_two::select_k_with_snapshot; use crate::policies::power_of_two::select_k_with_snapshot;
use crate::policies::{CacheCandidate, CacheCandidateProposal, GuardHints, SelectionProposal}; use crate::policies::{CacheCandidate, CacheCandidateProposal, GuardHints, SelectionProposal};
use crate::state::load_monitor::engine_reported_load::{
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad, EngineReportedWorkerLoad,
};
use crate::workers::Worker; use crate::workers::Worker;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::HashMap; use std::collections::HashMap;
@@ -173,13 +175,13 @@ pub struct CacheCandidateResolution {
/// The queue gate, in one place. Both subtle decisions live here: the /// The queue gate, in one place. Both subtle decisions live here: the
/// boundary is `<` (a worker AT the limit is already making this request /// boundary is `<` (a worker AT the limit is already making this request
/// wait), and an unknown queue ADMITS. The gate reads /// wait), and an unknown queue ADMITS. The gate reads
/// [`EngineWorkerLoad::num_waiting_reqs`] because that is what the request /// [`EngineReportedWorkerLoad::num_waiting_reqs`] because that is what the request
/// cares about, and the router-side in-flight counter cannot separate a /// cares about, and the router-side in-flight counter cannot separate a
/// running request from a waiting one — so there is no honest substitute, /// running request from a waiting one — so there is no honest substitute,
/// and the gate fails open rather than comparing the limit against a /// and the gate fails open rather than comparing the limit against a
/// different quantity. /// different quantity.
pub(crate) fn queue_gate_admits( pub(crate) fn queue_gate_admits(
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
worker: &Worker, worker: &Worker,
limit: Option<u64>, limit: Option<u64>,
) -> bool { ) -> bool {
@@ -196,7 +198,7 @@ pub(crate) fn queue_gate_admits(
/// an empty fleet, or a single worker with no fresh sample all make this /// an empty fleet, or a single worker with no fresh sample all make this
/// false — an unknown queue is not a proven full one. /// false — an unknown queue is not a proven full one.
pub(crate) fn fleet_is_all_queued( pub(crate) fn fleet_is_all_queued(
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
fleet: &[Arc<Worker>], fleet: &[Arc<Worker>],
limit: Option<u64>, limit: Option<u64>,
) -> bool { ) -> bool {
@@ -216,7 +218,7 @@ pub(crate) fn fleet_is_all_queued(
pub fn resolve_cache_candidates( pub fn resolve_cache_candidates(
proposal: &CacheCandidateProposal, proposal: &CacheCandidateProposal,
request_input_tokens: u64, request_input_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
fleet: &[Arc<Worker>], fleet: &[Arc<Worker>],
) -> CacheCandidateResolution { ) -> CacheCandidateResolution {
let queue_limit = proposal.worker_queue_limit; let queue_limit = proposal.worker_queue_limit;
@@ -397,7 +399,7 @@ pub fn resolve_prefill(
range: &CandidateRange<'_>, range: &CandidateRange<'_>,
proposal: &SelectionProposal, proposal: &SelectionProposal,
request_input_tokens: u64, request_input_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
queue_limit: Option<u64>, queue_limit: Option<u64>,
min_load_choices: usize, min_load_choices: usize,
) -> Option<FinalDecision> { ) -> Option<FinalDecision> {
@@ -431,7 +433,7 @@ pub fn resolve_prefill_admitted(
range: &CandidateRange<'_>, range: &CandidateRange<'_>,
proposal: &SelectionProposal, proposal: &SelectionProposal,
request_input_tokens: u64, request_input_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
queue_limit: Option<u64>, queue_limit: Option<u64>,
) -> Option<FinalDecision> { ) -> Option<FinalDecision> {
if !contains_worker(range, &proposal.primary) { if !contains_worker(range, &proposal.primary) {
@@ -492,7 +494,7 @@ pub fn resolve_decode(
domain: &CandidateDomain, domain: &CandidateDomain,
proposal: &SelectionProposal, proposal: &SelectionProposal,
request_kv_tokens: u64, request_kv_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
) -> Option<FinalDecision> { ) -> Option<FinalDecision> {
if domain.stage != RoutingStage::Decode || !contains_domain_worker(domain, &proposal.primary) { if domain.stage != RoutingStage::Decode || !contains_domain_worker(domain, &proposal.primary) {
return None; return None;
@@ -548,7 +550,7 @@ fn is_proposal_worker_eligible(proposal: &SelectionProposal, candidate: &Arc<Wor
/// Applies snapshot-backed capacity admission when native monitor data is complete. /// Applies snapshot-backed capacity admission when native monitor data is complete.
/// Workers without monitor data remain eligible and use Router-local ordering. /// Workers without monitor data remain eligible and use Router-local ordering.
fn has_kv_capacity(load: Option<&NativeCacheWorkerLoad>, requested_tokens: u64) -> bool { fn has_kv_capacity(load: Option<&EngineReportedSchedulingLoad>, requested_tokens: u64) -> bool {
let Some(load) = load else { let Some(load) = load else {
return true; return true;
}; };
@@ -560,7 +562,7 @@ fn is_prefill_admitted(
range: &CandidateRange<'_>, range: &CandidateRange<'_>,
worker: &Arc<Worker>, worker: &Arc<Worker>,
request_input_tokens: u64, request_input_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
) -> bool { ) -> bool {
let load = snapshot.fresh_native_cache_load_for_url(&worker.url); let load = snapshot.fresh_native_cache_load_for_url(&worker.url);
has_kv_capacity(load, request_input_tokens) has_kv_capacity(load, request_input_tokens)
@@ -576,7 +578,7 @@ fn is_prefill_admitted(
fn is_decode_admitted( fn is_decode_admitted(
worker: &Arc<Worker>, worker: &Arc<Worker>,
request_kv_tokens: u64, request_kv_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
) -> bool { ) -> bool {
has_kv_capacity( has_kv_capacity(
snapshot.fresh_native_cache_load_for_url(&worker.url), snapshot.fresh_native_cache_load_for_url(&worker.url),
@@ -692,8 +694,8 @@ fn materially_more_pressured(
/// External values are compared only when every candidate is present. Mixed /// External values are compared only when every candidate is present. Mixed
/// candidate sets use Router-local active load to preserve ordering. /// candidate sets use Router-local active load to preserve ordering.
pub(crate) struct FreshLoadLookup<'a> { pub(crate) struct FreshLoadLookup<'a> {
by_worker_id: HashMap<String, &'a NativeCacheWorkerLoad>, by_worker_id: HashMap<String, &'a EngineReportedSchedulingLoad>,
basic_by_worker_id: HashMap<String, &'a EngineWorkerLoad>, basic_by_worker_id: HashMap<String, &'a EngineReportedWorkerLoad>,
local_active_by_worker_id: HashMap<String, usize>, local_active_by_worker_id: HashMap<String, usize>,
compare_engine: bool, compare_engine: bool,
compare_basic_engine: bool, compare_basic_engine: bool,
@@ -701,13 +703,13 @@ pub(crate) struct FreshLoadLookup<'a> {
impl<'a> FreshLoadLookup<'a> { impl<'a> FreshLoadLookup<'a> {
pub(crate) fn new<'w>( pub(crate) fn new<'w>(
snapshot: Option<&'a EngineLoadSnapshot>, snapshot: Option<&'a EngineReportedLoadSnapshot>,
workers: impl IntoIterator<Item = &'w Arc<Worker>>, workers: impl IntoIterator<Item = &'w Arc<Worker>>,
) -> Self { ) -> Self {
let workers: Vec<&Arc<Worker>> = workers.into_iter().collect(); let workers: Vec<&Arc<Worker>> = workers.into_iter().collect();
let local_active_by_worker_id: HashMap<String, usize> = workers let local_active_by_worker_id: HashMap<String, usize> = workers
.iter() .iter()
.map(|worker| (worker.id.0.clone(), worker.active_load())) .map(|worker| (worker.id.0.clone(), worker.router_inflight_load()))
.collect(); .collect();
let by_worker_id = snapshot let by_worker_id = snapshot
.into_iter() .into_iter()
@@ -745,14 +747,14 @@ impl<'a> FreshLoadLookup<'a> {
pub(crate) fn get( pub(crate) fn get(
&self, &self,
worker_id: &crate::discovery::WorkerId, worker_id: &crate::discovery::WorkerId,
) -> Option<&'a NativeCacheWorkerLoad> { ) -> Option<&'a EngineReportedSchedulingLoad> {
self.by_worker_id.get(worker_id.0.as_str()).copied() self.by_worker_id.get(worker_id.0.as_str()).copied()
} }
fn comparable_get( fn comparable_get(
&self, &self,
worker_id: &crate::discovery::WorkerId, worker_id: &crate::discovery::WorkerId,
) -> Option<&'a NativeCacheWorkerLoad> { ) -> Option<&'a EngineReportedSchedulingLoad> {
self.compare_engine.then(|| self.get(worker_id)).flatten() self.compare_engine.then(|| self.get(worker_id)).flatten()
} }
@@ -853,7 +855,7 @@ impl<'a> FreshLoadLookup<'a> {
} }
struct PressureKey<'a> { struct PressureKey<'a> {
load: Option<&'a NativeCacheWorkerLoad>, load: Option<&'a EngineReportedSchedulingLoad>,
local_active: usize, local_active: usize,
} }
@@ -861,7 +863,7 @@ fn range_fallback(
range: &CandidateRange<'_>, range: &CandidateRange<'_>,
legal: &[Arc<Worker>], legal: &[Arc<Worker>],
request_input_tokens: u64, request_input_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
queue_limit: Option<u64>, queue_limit: Option<u64>,
) -> Option<(Arc<Worker>, DecisionReason)> { ) -> Option<(Arc<Worker>, DecisionReason)> {
let admitted = legal let admitted = legal
@@ -922,7 +924,7 @@ fn legal_prefill_candidates(
fn decode_domain_fallback( fn decode_domain_fallback(
domain: &CandidateDomain, domain: &CandidateDomain,
request_kv_tokens: u64, request_kv_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
) -> Option<(Arc<Worker>, DecisionReason)> { ) -> Option<(Arc<Worker>, DecisionReason)> {
let admitted = domain let admitted = domain
.workers .workers
@@ -940,7 +942,7 @@ fn decode_domain_fallback(
pub(crate) fn compare_prefill_pressure( pub(crate) fn compare_prefill_pressure(
left: &Arc<Worker>, left: &Arc<Worker>,
right: &Arc<Worker>, right: &Arc<Worker>,
snapshot: Option<&EngineLoadSnapshot>, snapshot: Option<&EngineReportedLoadSnapshot>,
) -> Ordering { ) -> Ordering {
match snapshot.and_then(|snapshot| { match snapshot.and_then(|snapshot| {
Some(( Some((
@@ -948,13 +950,19 @@ pub(crate) fn compare_prefill_pressure(
snapshot.fresh_native_cache_load_for_url(&right.url)?, snapshot.fresh_native_cache_load_for_url(&right.url)?,
)) ))
}) { }) {
Some((left_load, right_load)) => compare_prefill_load(left_load, right_load) Some((left_load, right_load)) => {
.then_with(|| left.active_load().cmp(&right.active_load())), compare_prefill_load(left_load, right_load).then_with(|| {
None => left.active_load().cmp(&right.active_load()), left.router_inflight_load()
.cmp(&right.router_inflight_load())
})
}
None => left
.router_inflight_load()
.cmp(&right.router_inflight_load()),
} }
} }
fn prefill_pressure_key(load: &NativeCacheWorkerLoad) -> (u64, u64, u64) { fn prefill_pressure_key(load: &EngineReportedSchedulingLoad) -> (u64, u64, u64) {
( (
load.num_waiting_uncached_tokens, load.num_waiting_uncached_tokens,
load.num_waiting_reqs, load.num_waiting_reqs,
@@ -962,7 +970,10 @@ fn prefill_pressure_key(load: &NativeCacheWorkerLoad) -> (u64, u64, u64) {
) )
} }
fn compare_prefill_load(left: &NativeCacheWorkerLoad, right: &NativeCacheWorkerLoad) -> Ordering { fn compare_prefill_load(
left: &EngineReportedSchedulingLoad,
right: &EngineReportedSchedulingLoad,
) -> Ordering {
match ( match (
left.estimated_prefill_queue_ms, left.estimated_prefill_queue_ms,
right.estimated_prefill_queue_ms, right.estimated_prefill_queue_ms,
@@ -978,7 +989,7 @@ fn compare_prefill_load(left: &NativeCacheWorkerLoad, right: &NativeCacheWorkerL
pub(crate) fn compare_decode_pressure( pub(crate) fn compare_decode_pressure(
left: &Arc<Worker>, left: &Arc<Worker>,
right: &Arc<Worker>, right: &Arc<Worker>,
snapshot: Option<&EngineLoadSnapshot>, snapshot: Option<&EngineReportedLoadSnapshot>,
) -> Ordering { ) -> Ordering {
match snapshot.and_then(|snapshot| { match snapshot.and_then(|snapshot| {
Some(( Some((
@@ -986,13 +997,22 @@ pub(crate) fn compare_decode_pressure(
snapshot.fresh_native_cache_load_for_url(&right.url)?, snapshot.fresh_native_cache_load_for_url(&right.url)?,
)) ))
}) { }) {
Some((left_load, right_load)) => compare_decode_load(left_load, right_load) Some((left_load, right_load)) => {
.then_with(|| left.active_load().cmp(&right.active_load())), compare_decode_load(left_load, right_load).then_with(|| {
None => left.active_load().cmp(&right.active_load()), left.router_inflight_load()
.cmp(&right.router_inflight_load())
})
}
None => left
.router_inflight_load()
.cmp(&right.router_inflight_load()),
} }
} }
fn compare_decode_load(left: &NativeCacheWorkerLoad, right: &NativeCacheWorkerLoad) -> Ordering { fn compare_decode_load(
left: &EngineReportedSchedulingLoad,
right: &EngineReportedSchedulingLoad,
) -> Ordering {
let kv_usage = match (left.max_total_num_tokens, right.max_total_num_tokens) { let kv_usage = match (left.max_total_num_tokens, right.max_total_num_tokens) {
(left_cap, right_cap) if left_cap > 0 && right_cap > 0 => u128::from(left.num_used_tokens) (left_cap, right_cap) if left_cap > 0 && right_cap > 0 => u128::from(left.num_used_tokens)
.saturating_mul(u128::from(right_cap)) .saturating_mul(u128::from(right_cap))
@@ -1010,7 +1030,7 @@ fn pressure_guard_prefers_backup(
primary: &Arc<Worker>, primary: &Arc<Worker>,
backup: &Arc<Worker>, backup: &Arc<Worker>,
hints: &GuardHints, hints: &GuardHints,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
) -> bool { ) -> bool {
if !hints.enable_pressure_guard { if !hints.enable_pressure_guard {
return false; return false;
@@ -1059,15 +1079,15 @@ mod tests {
})) }))
} }
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot { fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
7, 7,
entries entries
.iter() .iter()
.map(|(worker, running, waiting, used, capacity)| { .map(|(worker, running, waiting, used, capacity)| {
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: *running, num_running_reqs: *running,
num_waiting_reqs: *waiting, num_waiting_reqs: *waiting,
num_waiting_uncached_tokens: *waiting, num_waiting_uncached_tokens: *waiting,
@@ -1693,7 +1713,7 @@ mod tests {
]; ];
let proposal = SelectionProposal::primary(Arc::clone(&primary)); let proposal = SelectionProposal::primary(Arc::clone(&primary));
let load = |waiting: u64, waiting_uncached: u64, total: u64, max_total: u64| { let load = |waiting: u64, waiting_uncached: u64, total: u64, max_total: u64| {
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: 0, num_running_reqs: 0,
num_waiting_reqs: waiting, num_waiting_reqs: waiting,
num_waiting_uncached_tokens: waiting_uncached, num_waiting_uncached_tokens: waiting_uncached,
@@ -1710,7 +1730,7 @@ mod tests {
// waits 5 (over the limit) behind 1 uncached token, busy_unqueued // waits 5 (over the limit) behind 1 uncached token, busy_unqueued
// waits 3 (under the limit) behind 1000 uncached tokens. The primary // waits 3 (under the limit) behind 1000 uncached tokens. The primary
// is KV-full, so it is not admitted at all. // is KV-full, so it is not admitted at all.
let loads = EngineLoadSnapshot::from_native_cache_workers( let loads = EngineReportedLoadSnapshot::from_native_cache_workers(
7, 7,
[ [
(primary.url.clone(), load(0, 0, 10_000, 10_000)), (primary.url.clone(), load(0, 0, 10_000, 10_000)),
@@ -8,15 +8,15 @@ use crate::policies::admission::{
compare_decode_pressure, resolve_decode, CandidateDomain, DecisionReason, FinalDecision, compare_decode_pressure, resolve_decode, CandidateDomain, DecisionReason, FinalDecision,
RoutingStage, RoutingStage,
}; };
use crate::policies::engine_load::EngineLoadSnapshot;
use crate::policies::registry::select_decode_with_affinity; use crate::policies::registry::select_decode_with_affinity;
use crate::policies::{ProposalKind, SelectionProposal}; use crate::policies::{ProposalKind, SelectionProposal};
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
use rand::Rng; use rand::Rng;
use std::sync::Arc; use std::sync::Arc;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct DecodeSelectionContext<'a> { pub struct DecodeSelectionContext<'a> {
load_snapshot: Option<&'a EngineLoadSnapshot>, load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
prefill_url: Option<&'a str>, prefill_url: Option<&'a str>,
} }
@@ -29,12 +29,12 @@ impl<'a> DecodeSelectionContext<'a> {
} }
/// Engine load snapshot captured at request ingress. /// Engine load snapshot captured at request ingress.
pub fn with_load_snapshot(mut self, load_snapshot: &'a EngineLoadSnapshot) -> Self { pub fn with_load_snapshot(mut self, load_snapshot: &'a EngineReportedLoadSnapshot) -> Self {
self.load_snapshot = Some(load_snapshot); self.load_snapshot = Some(load_snapshot);
self self
} }
pub fn load_snapshot(&self) -> Option<&EngineLoadSnapshot> { pub fn load_snapshot(&self) -> Option<&EngineReportedLoadSnapshot> {
self.load_snapshot self.load_snapshot
} }
@@ -62,7 +62,7 @@ pub fn resolve_decode_with_capacity_fallback(
domain: &CandidateDomain, domain: &CandidateDomain,
proposal: &SelectionProposal, proposal: &SelectionProposal,
request_kv_tokens: u64, request_kv_tokens: u64,
snapshot: &EngineLoadSnapshot, snapshot: &EngineReportedLoadSnapshot,
) -> Option<FinalDecision> { ) -> Option<FinalDecision> {
if let Some(decision) = resolve_decode(domain, proposal, request_kv_tokens, snapshot) { if let Some(decision) = resolve_decode(domain, proposal, request_kv_tokens, snapshot) {
return Some(decision); return Some(decision);
@@ -7,7 +7,6 @@ use crate::config::{
use crate::discovery::ModelId; use crate::discovery::ModelId;
use crate::policies::{ use crate::policies::{
cache_aware::CacheAwarePolicy, cache_aware::CacheAwarePolicy,
kv_events::{BlockSizeOracle, HashTree},
load_based::LoadBasedPolicy, load_based::LoadBasedPolicy,
power_of_two::PowerOfTwoChoicesPolicy, power_of_two::PowerOfTwoChoicesPolicy,
random::RandomPolicy, random::RandomPolicy,
@@ -20,6 +19,7 @@ use crate::policies::{
sticky::StickyPolicy, sticky::StickyPolicy,
Policy, PolicyRegistry, Policy, PolicyRegistry,
}; };
use crate::state::kv_events::{BlockSizeOracle, HashTree};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -237,7 +237,7 @@ pub fn build_registry_with_defaults(cfg: &Config) -> Result<PolicyRegistry> {
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{ use crate::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ProxyConfig, ServerConfig, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ProxyConfig, ServerConfig,
StaticUrlsDiscoveryConfig, StaticUrlsDiscoveryConfig,
}; };
@@ -353,7 +353,7 @@ mod tests {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -48,9 +48,11 @@ impl ScoringPolicy for LoadBasedPolicy {
mod tests { mod tests {
use super::*; use super::*;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad};
use crate::policies::scoring::argmax::TIE_EPSILON; use crate::policies::scoring::argmax::TIE_EPSILON;
use crate::policies::Policy; use crate::policies::Policy;
use crate::state::load_monitor::engine_reported_load::{
EngineReportedLoadSnapshot, EngineReportedWorkerLoad,
};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
@@ -94,7 +96,10 @@ mod tests {
let ok = (scores[i] > scores[j] + TIE_EPSILON, scores[i].is_nan()); let ok = (scores[i] > scores[j] + TIE_EPSILON, scores[i].is_nan());
assert_eq!(ok, (loads[i] < loads[j], false), "{spec} scored {scores:?}"); assert_eq!(ok, (loads[i] < loads[j], false), "{spec} scored {scores:?}");
} }
let got = p.select(&ws, &ctx).expect("non-empty").active_load(); let got = p
.select(&ws, &ctx)
.expect("non-empty")
.router_inflight_load();
assert_eq!(got, *loads.iter().min().expect("non-empty"), "{spec}"); assert_eq!(got, *loads.iter().min().expect("non-empty"), "{spec}");
} }
} }
@@ -107,12 +112,12 @@ mod tests {
// After the request snapshot, local counters say w0 is lighter. // After the request snapshot, local counters say w0 is lighter.
// The policy must still preserve the frozen Engine Load ordering. // The policy must still preserve the frozen Engine Load ordering.
let _after_snapshot: Vec<_> = (0..10).map(|_| w1.load_guard()).collect(); let _after_snapshot: Vec<_> = (0..10).map(|_| w1.load_guard()).collect();
let snapshot = EngineLoadSnapshot::from_workers( let snapshot = EngineReportedLoadSnapshot::from_workers(
23, 23,
HashMap::from([ HashMap::from([
( (
w0.url.clone(), w0.url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 50, num_running_reqs: 50,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -122,7 +127,7 @@ mod tests {
), ),
( (
w1.url.clone(), w1.url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 1, num_running_reqs: 1,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -148,12 +153,12 @@ mod tests {
let w0 = worker("w0"); let w0 = worker("w0");
let w1 = worker("w1"); let w1 = worker("w1");
let captured_at = Instant::now(); let captured_at = Instant::now();
let snapshot = EngineLoadSnapshot::from_workers( let snapshot = EngineReportedLoadSnapshot::from_workers(
37, 37,
HashMap::from([ HashMap::from([
( (
w0.url.clone(), w0.url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 0, num_running_reqs: 0,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -163,7 +168,7 @@ mod tests {
), ),
( (
w1.url.clone(), w1.url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 1, num_running_reqs: 1,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -192,12 +197,12 @@ mod tests {
let _before_snapshot = [w0.timestamped_load_guard(), w0.timestamped_load_guard()]; let _before_snapshot = [w0.timestamped_load_guard(), w0.timestamped_load_guard()];
std::thread::sleep(std::time::Duration::from_millis(5)); std::thread::sleep(std::time::Duration::from_millis(5));
let captured_at = Instant::now(); let captured_at = Instant::now();
let snapshot = EngineLoadSnapshot::from_workers( let snapshot = EngineReportedLoadSnapshot::from_workers(
41, 41,
HashMap::from([ HashMap::from([
( (
w0.url.clone(), w0.url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 0, num_running_reqs: 0,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -207,7 +212,7 @@ mod tests {
), ),
( (
w1.url.clone(), w1.url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 1, num_running_reqs: 1,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -233,11 +238,11 @@ mod tests {
let w0 = worker("w0"); let w0 = worker("w0");
let w1 = worker("w1"); let w1 = worker("w1");
let _local_load = [w0.load_guard(), w0.load_guard()]; let _local_load = [w0.load_guard(), w0.load_guard()];
let snapshot = EngineLoadSnapshot::from_workers( let snapshot = EngineReportedLoadSnapshot::from_workers(
43, 43,
HashMap::from([( HashMap::from([(
w0.url.clone(), w0.url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 0, num_running_reqs: 0,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
+11 -12
View File
@@ -1,14 +1,11 @@
// 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
pub mod active_load;
pub mod admission; pub mod admission;
pub mod buckets; pub mod buckets;
pub mod cache_aware; pub mod cache_aware;
pub mod decode; pub mod decode;
pub mod engine_load;
pub mod factory; pub mod factory;
pub mod kv_events;
pub mod load_based; pub mod load_based;
pub mod power_of_two; pub mod power_of_two;
pub mod prefix_provider; pub mod prefix_provider;
@@ -22,9 +19,9 @@ pub mod sticky;
use crate::discovery::ModelId; use crate::discovery::ModelId;
use crate::policies::buckets::{BucketRequest, BucketSelector}; use crate::policies::buckets::{BucketRequest, BucketSelector};
use crate::policies::engine_load::EngineLoadSnapshot;
use crate::policies::scoring::{EligibilityFilter, ScoringPolicy}; use crate::policies::scoring::{EligibilityFilter, ScoringPolicy};
use crate::server::metrics::MetricsRegistry; use crate::server::metrics::MetricsRegistry;
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
use crate::tokenizer::{adapter, TokenizerRegistry}; use crate::tokenizer::{adapter, TokenizerRegistry};
use crate::workers::Worker; use crate::workers::Worker;
use dashmap::DashMap; use dashmap::DashMap;
@@ -176,7 +173,7 @@ pub struct SelectionContext<'a> {
input_tokens: Option<u64>, input_tokens: Option<u64>,
request_tokens: Option<&'a [u32]>, request_tokens: Option<&'a [u32]>,
external_prefix: Option<&'a ExternalPrefixSignal>, external_prefix: Option<&'a ExternalPrefixSignal>,
load_snapshot: Option<&'a EngineLoadSnapshot>, load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
prefill_cache_bucket: Option<(&'a BucketSelector, BucketRequest)>, prefill_cache_bucket: Option<(&'a BucketSelector, BucketRequest)>,
affinity_lookup_enabled: bool, affinity_lookup_enabled: bool,
affinity_assignment_enabled: bool, affinity_assignment_enabled: bool,
@@ -254,7 +251,7 @@ impl<'a> SelectionContext<'a> {
} }
/// Attaches the engine load snapshot captured at request ingress. /// Attaches the engine load snapshot captured at request ingress.
pub fn with_load_snapshot(mut self, load_snapshot: &'a EngineLoadSnapshot) -> Self { pub fn with_load_snapshot(mut self, load_snapshot: &'a EngineReportedLoadSnapshot) -> Self {
self.load_snapshot = Some(load_snapshot); self.load_snapshot = Some(load_snapshot);
self self
} }
@@ -315,7 +312,7 @@ impl<'a> SelectionContext<'a> {
self.external_prefix self.external_prefix
} }
pub fn load_snapshot(&self) -> Option<&EngineLoadSnapshot> { pub fn load_snapshot(&self) -> Option<&EngineReportedLoadSnapshot> {
self.load_snapshot self.load_snapshot
} }
@@ -592,10 +589,12 @@ mod tests {
resolve_cache_candidates, resolve_prefill, CandidateRange, DecisionReason, FreshLoadLookup, resolve_cache_candidates, resolve_prefill, CandidateRange, DecisionReason, FreshLoadLookup,
}; };
use crate::policies::cache_aware::CacheAwarePolicy; use crate::policies::cache_aware::CacheAwarePolicy;
use crate::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy; use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
use crate::policies::round_robin::RoundRobinPolicy; use crate::policies::round_robin::RoundRobinPolicy;
use crate::policies::session_aware::SessionAwarePolicy; use crate::policies::session_aware::SessionAwarePolicy;
use crate::state::load_monitor::engine_reported_load::{
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
@@ -1222,15 +1221,15 @@ mod tests {
assert!(proposal.backup.is_some()); assert!(proposal.backup.is_some());
} }
fn snapshot(entries: &[(&Arc<Worker>, TestEngineLoad)]) -> EngineLoadSnapshot { fn snapshot(entries: &[(&Arc<Worker>, TestEngineLoad)]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
1, 1,
entries entries
.iter() .iter()
.map(|(worker, aggregate)| { .map(|(worker, aggregate)| {
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: aggregate.num_running_reqs, num_running_reqs: aggregate.num_running_reqs,
num_waiting_reqs: aggregate.num_waiting_reqs, num_waiting_reqs: aggregate.num_waiting_reqs,
num_waiting_uncached_tokens: aggregate num_waiting_uncached_tokens: aggregate
@@ -1616,7 +1615,7 @@ mod tests {
fn missing_engine_snapshot_does_not_hard_reject_a_registry_healthy_primary() { fn missing_engine_snapshot_does_not_hard_reject_a_registry_healthy_primary() {
let primary = worker("primary"); let primary = worker("primary");
let workers = vec![Arc::clone(&primary)]; let workers = vec![Arc::clone(&primary)];
let snapshot = EngineLoadSnapshot::default(); let snapshot = EngineReportedLoadSnapshot::default();
let decision = resolve_prefill( let decision = resolve_prefill(
&CandidateRange::global(&workers), &CandidateRange::global(&workers),
@@ -15,8 +15,8 @@
use crate::config::DEFAULT_MIN_LOAD_CHOICES; use crate::config::DEFAULT_MIN_LOAD_CHOICES;
use crate::policies::admission::{compare_prefill_pressure, queue_gate_admits}; use crate::policies::admission::{compare_prefill_pressure, queue_gate_admits};
use crate::policies::engine_load::EngineLoadSnapshot;
use crate::policies::{Policy, ProposalKind, SelectionContext, SelectionProposal}; use crate::policies::{Policy, ProposalKind, SelectionContext, SelectionProposal};
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
use crate::workers::Worker; use crate::workers::Worker;
use rand::seq::index::sample; use rand::seq::index::sample;
use rand::Rng; use rand::Rng;
@@ -81,7 +81,7 @@ impl Policy for PowerOfTwoChoicesPolicy {
pub(crate) fn select_k_with_snapshot( pub(crate) fn select_k_with_snapshot(
workers: &[Arc<Worker>], workers: &[Arc<Worker>],
snapshot: Option<&EngineLoadSnapshot>, snapshot: Option<&EngineReportedLoadSnapshot>,
choices: usize, choices: usize,
queue_limit: Option<u64>, queue_limit: Option<u64>,
) -> Option<Arc<Worker>> { ) -> Option<Arc<Worker>> {
@@ -95,7 +95,7 @@ pub(crate) fn select_k_with_snapshot(
/// a queueing worker while an unqueued one exists. /// a queueing worker while an unqueued one exists.
fn sample_pool<'w>( fn sample_pool<'w>(
workers: &'w [Arc<Worker>], workers: &'w [Arc<Worker>],
snapshot: Option<&EngineLoadSnapshot>, snapshot: Option<&EngineReportedLoadSnapshot>,
queue_limit: Option<u64>, queue_limit: Option<u64>,
) -> Cow<'w, [Arc<Worker>]> { ) -> Cow<'w, [Arc<Worker>]> {
// Without a limit there is nothing to gate on, and without a snapshot // Without a limit there is nothing to gate on, and without a snapshot
@@ -138,7 +138,7 @@ fn sample_pool<'w>(
/// unwinding whichever request task was selecting at the time. /// unwinding whichever request task was selecting at the time.
fn best_two_of_sample( fn best_two_of_sample(
pool: &[Arc<Worker>], pool: &[Arc<Worker>],
snapshot: Option<&EngineLoadSnapshot>, snapshot: Option<&EngineReportedLoadSnapshot>,
choices: usize, choices: usize,
) -> Option<(Arc<Worker>, Option<Arc<Worker>>)> { ) -> Option<(Arc<Worker>, Option<Arc<Worker>>)> {
let len = pool.len(); let len = pool.len();
@@ -186,7 +186,7 @@ fn best_two_of_sample(
mod tests { mod tests {
use super::*; use super::*;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::policies::engine_load::NativeCacheWorkerLoad; use crate::state::load_monitor::engine_reported_load::EngineReportedSchedulingLoad;
use std::time::Instant; use std::time::Instant;
fn worker(id: &str) -> Arc<Worker> { fn worker(id: &str) -> Arc<Worker> {
@@ -202,15 +202,15 @@ mod tests {
/// Snapshot keyed on waiting depth; `waiting` sets both the queue-gate /// Snapshot keyed on waiting depth; `waiting` sets both the queue-gate
/// reading (`num_waiting_reqs`) and the pressure ordering /// reading (`num_waiting_reqs`) and the pressure ordering
/// (`num_waiting_uncached_tokens`), so one knob drives both. /// (`num_waiting_uncached_tokens`), so one knob drives both.
fn snapshot(entries: &[(&Arc<Worker>, u64)]) -> EngineLoadSnapshot { fn snapshot(entries: &[(&Arc<Worker>, u64)]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
7, 7,
entries entries
.iter() .iter()
.map(|(worker, waiting)| { .map(|(worker, waiting)| {
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: 0, num_running_reqs: 0,
num_waiting_reqs: *waiting, num_waiting_reqs: *waiting,
num_waiting_uncached_tokens: *waiting, num_waiting_uncached_tokens: *waiting,
@@ -234,8 +234,8 @@ mod tests {
/// `compare_prefill_pressure` intransitive: a slow worker with a shallow /// `compare_prefill_pressure` intransitive: a slow worker with a shallow
/// queue loses to a fast worker with a deep one on the estimate, while /// queue loses to a fast worker with a deep one on the estimate, while
/// both are ordered against an estimate-less worker on waiting tokens. /// both are ordered against an estimate-less worker on waiting tokens.
fn mixed_estimate_snapshot(workers: &[Arc<Worker>]) -> EngineLoadSnapshot { fn mixed_estimate_snapshot(workers: &[Arc<Worker>]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
11, 11,
workers workers
.iter() .iter()
@@ -244,7 +244,7 @@ mod tests {
let waiting = (index as u64 * 7) % 13; let waiting = (index as u64 * 7) % 13;
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: 0, num_running_reqs: 0,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_waiting_uncached_tokens: waiting, num_waiting_uncached_tokens: waiting,
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
use super::ExternalPrefixSignal; use super::ExternalPrefixSignal;
use crate::policies::kv_events::{ use crate::state::kv_events::{
compute_block_hashes, compute_block_hashes_bigram, BlockSizeOracle, HashTree, compute_block_hashes, compute_block_hashes_bigram, BlockSizeOracle, HashTree,
}; };
use sgl_kv_indexer::{PrefixMatch, PrefixOutcome}; use sgl_kv_indexer::{PrefixMatch, PrefixOutcome};
@@ -211,7 +211,7 @@ impl PdPoolResolver {
/// ///
/// 1. **Same-host preference.** Parse the host portion of both URLs /// 1. **Same-host preference.** Parse the host portion of both URLs
/// (`url::Url::host_str`). If any candidate shares the host AND has /// (`url::Url::host_str`). If any candidate shares the host AND has
/// a closed circuit breaker AND has `active_load <= /// a closed circuit breaker AND has `router_inflight_load <=
/// AFFINITY_LOAD_TOLERANCE × median(decode_pool_load)`, return it. /// AFFINITY_LOAD_TOLERANCE × median(decode_pool_load)`, return it.
/// 2. **Fallback: min-load among closed-breaker candidates.** No /// 2. **Fallback: min-load among closed-breaker candidates.** No
/// same-host peer, or the same-host peer was filtered by rule 1's /// same-host peer, or the same-host peer was filtered by rule 1's
@@ -257,7 +257,7 @@ pub fn select_decode_with_affinity(
let load_tolerance = if healthy.is_empty() { let load_tolerance = if healthy.is_empty() {
0 0
} else { } else {
let mut loads: Vec<usize> = healthy.iter().map(|w| w.active_load()).collect(); let mut loads: Vec<usize> = healthy.iter().map(|w| w.router_inflight_load()).collect();
loads.sort_unstable(); loads.sort_unstable();
let median = loads[loads.len() / 2]; let median = loads[loads.len() / 2];
((median as f64) * AFFINITY_LOAD_TOLERANCE).ceil() as usize ((median as f64) * AFFINITY_LOAD_TOLERANCE).ceil() as usize
@@ -267,7 +267,7 @@ pub fn select_decode_with_affinity(
if let Some(host) = prefill_host.as_deref() { if let Some(host) = prefill_host.as_deref() {
let affinity_peer = healthy.iter().find(|w| { let affinity_peer = healthy.iter().find(|w| {
host_of(&w.url).as_deref() == Some(host) host_of(&w.url).as_deref() == Some(host)
&& (load_tolerance == 0 || w.active_load() <= load_tolerance) && (load_tolerance == 0 || w.router_inflight_load() <= load_tolerance)
}); });
if let Some(w) = affinity_peer { if let Some(w) = affinity_peer {
return Some(Arc::clone(w)); return Some(Arc::clone(w));
@@ -275,14 +275,17 @@ pub fn select_decode_with_affinity(
} }
// Rule 2: min-load among healthy. // Rule 2: min-load among healthy.
if let Some(w) = healthy.iter().min_by_key(|w| w.active_load()) { if let Some(w) = healthy.iter().min_by_key(|w| w.router_inflight_load()) {
return Some(Arc::clone(w)); return Some(Arc::clone(w));
} }
// Rule 3: last-resort min-load over all candidates (every // Rule 3: last-resort min-load over all candidates (every
// breaker is open). The caller's dispatch will likely fail and // breaker is open). The caller's dispatch will likely fail and
// surface `BreakerOpen`, but the selection function stays total. // surface `BreakerOpen`, but the selection function stays total.
candidates.iter().min_by_key(|w| w.active_load()).cloned() candidates
.iter()
.min_by_key(|w| w.router_inflight_load())
.cloned()
} }
/// Parse the host portion of a worker URL. Returns `None` when the URL /// Parse the host portion of a worker URL. Returns `None` when the URL
@@ -23,7 +23,7 @@ impl Overloaded {
impl EligibilityFilter for Overloaded { impl EligibilityFilter for Overloaded {
fn keep(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Vec<bool> { fn keep(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Vec<bool> {
(workers.iter()) (workers.iter())
.map(|w| w.active_load() < self.max_in_flight) .map(|w| w.router_inflight_load() < self.max_in_flight)
.collect() .collect()
} }
@@ -42,7 +42,7 @@ impl Policy for Overloaded {
.collect(); .collect();
eligible eligible
.iter() .iter()
.min_by_key(|w| w.active_load()) .min_by_key(|w| w.router_inflight_load())
.map(Arc::clone) .map(Arc::clone)
} }
@@ -52,10 +52,13 @@ impl Selector for Argmax {
); );
band = (0..workers.len()).collect(); band = (0..workers.len()).collect();
} }
let min_load = band.iter().map(|&i| workers[i].active_load()).min()?; let min_load = band
.iter()
.map(|&i| workers[i].router_inflight_load())
.min()?;
let tied: Vec<usize> = band let tied: Vec<usize> = band
.into_iter() .into_iter()
.filter(|&i| workers[i].active_load() == min_load) .filter(|&i| workers[i].router_inflight_load() == min_load)
.collect(); .collect();
let k = self.rotor.fetch_add(1, Ordering::Relaxed) % tied.len(); let k = self.rotor.fetch_add(1, Ordering::Relaxed) % tied.len();
Some(tied[k]) Some(tied[k])
@@ -417,11 +417,13 @@ mod tests {
use crate::config::AffinityConfig; use crate::config::AffinityConfig;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::policies::admission::{resolve_prefill, CandidateRange}; use crate::policies::admission::{resolve_prefill, CandidateRange};
use crate::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
use crate::policies::load_based::LoadBasedPolicy; use crate::policies::load_based::LoadBasedPolicy;
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy; use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
use crate::policies::round_robin::RoundRobinPolicy; use crate::policies::round_robin::RoundRobinPolicy;
use crate::policies::session_aware::SessionAwarePolicy; use crate::policies::session_aware::SessionAwarePolicy;
use crate::state::load_monitor::engine_reported_load::{
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
@@ -439,15 +441,15 @@ mod tests {
vec![worker("a"), worker("b"), worker("c")] vec![worker("a"), worker("b"), worker("c")]
} }
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot { fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
1, 1,
entries entries
.iter() .iter()
.map(|(worker, running, waiting, used, capacity)| { .map(|(worker, running, waiting, used, capacity)| {
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: *running, num_running_reqs: *running,
num_waiting_reqs: *waiting, num_waiting_reqs: *waiting,
num_waiting_uncached_tokens: *waiting, num_waiting_uncached_tokens: *waiting,
@@ -789,7 +791,7 @@ mod tests {
); );
assert_eq!(proposal.primary.id, ws[2].id); assert_eq!(proposal.primary.id, ws[2].id);
let snapshot = EngineLoadSnapshot::default(); let snapshot = EngineReportedLoadSnapshot::default();
let decision = resolve_prefill( let decision = resolve_prefill(
&CandidateRange::global(&ws), &CandidateRange::global(&ws),
&proposal, &proposal,
@@ -4,10 +4,10 @@
//! Prefix-cache scores from the KV-event [`HashTree`]. //! Prefix-cache scores from the KV-event [`HashTree`].
use super::{EligibilityFilter, ScoringPolicy}; use super::{EligibilityFilter, ScoringPolicy};
use crate::policies::kv_events::{ use crate::policies::SelectionContext;
use crate::state::kv_events::{
compute_block_hashes, compute_block_hashes_bigram, BlockSizeOracle, HashTree, compute_block_hashes, compute_block_hashes_bigram, BlockSizeOracle, HashTree,
}; };
use crate::policies::SelectionContext;
use crate::workers::Worker; use crate::workers::Worker;
use std::sync::Arc; use std::sync::Arc;
@@ -122,7 +122,7 @@ impl EligibilityFilter for PrefixCachePolicy {
mod tests { mod tests {
use super::*; use super::*;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::policies::kv_events::KvWorkerId; use crate::state::kv_events::KvWorkerId;
const BLOCK: usize = 4; const BLOCK: usize = 4;
@@ -35,11 +35,11 @@ use crate::policies::buckets::{BucketRequest, BucketSelector};
use crate::policies::decode::{ use crate::policies::decode::{
build_decode_policy, resolve_decode_with_capacity_fallback, DecodeSelectionContext, build_decode_policy, resolve_decode_with_capacity_fallback, DecodeSelectionContext,
}; };
use crate::policies::engine_load::EngineLoadSnapshot;
use crate::policies::{ use crate::policies::{
ExternalPrefixSignal, Policy, PrefillProposal, ProposalKind, SelectionContext, ExternalPrefixSignal, Policy, PrefillProposal, ProposalKind, SelectionContext,
}; };
use crate::server::metrics::{CacheAwareDecision, MetricsRegistry, PolicySelectionFailureReason}; use crate::server::metrics::{CacheAwareDecision, MetricsRegistry, PolicySelectionFailureReason};
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
use crate::workers::Worker; use crate::workers::Worker;
/// Everything one prefill selection reads. Collaborators first, then the /// Everything one prefill selection reads. Collaborators first, then the
@@ -61,7 +61,7 @@ pub(crate) struct PrefillSelectionInputs<'a> {
/// per-domain rung panics without it. `Policy::needs_load_snapshot` /// per-domain rung panics without it. `Policy::needs_load_snapshot`
/// defaults to `uses_shared_prefill_admission`, which is what keeps the /// defaults to `uses_shared_prefill_admission`, which is what keeps the
/// two in step for the ingress caller. /// two in step for the ingress caller.
pub load_snapshot: Option<&'a EngineLoadSnapshot>, pub load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
pub workers: &'a [Arc<Worker>], pub workers: &'a [Arc<Worker>],
pub ttft_slo_ms: Option<u64>, pub ttft_slo_ms: Option<u64>,
pub tps_slo: Option<f64>, pub tps_slo: Option<f64>,
@@ -607,7 +607,7 @@ pub(crate) struct DecodeSelectionInputs<'a> {
/// Required: every rung resolves its proposal against the snapshot, so /// Required: every rung resolves its proposal against the snapshot, so
/// without one the ladder reports no peer at all rather than picking one /// without one the ladder reports no peer at all rather than picking one
/// blind. /// blind.
pub load_snapshot: Option<&'a EngineLoadSnapshot>, pub load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
} }
/// Runs the decode selection ladder. /// Runs the decode selection ladder.
@@ -699,12 +699,14 @@ mod tests {
use crate::policies::admission::{resolve_prefill_admitted, CandidateRange, DecisionReason}; use crate::policies::admission::{resolve_prefill_admitted, CandidateRange, DecisionReason};
use crate::policies::buckets::BucketSelector; use crate::policies::buckets::BucketSelector;
use crate::policies::cache_aware::CacheAwarePolicy; use crate::policies::cache_aware::CacheAwarePolicy;
use crate::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy; use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
use crate::policies::{ExternalPrefixSignal, Policy, ProposalKind, SelectionProposal}; use crate::policies::{ExternalPrefixSignal, Policy, ProposalKind, SelectionProposal};
use crate::server::metrics::{ use crate::server::metrics::{
CacheAwareDecision, MetricsRegistry, PolicySelectionFailureReason, CacheAwareDecision, MetricsRegistry, PolicySelectionFailureReason,
}; };
use crate::state::load_monitor::engine_reported_load::{
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
};
use crate::workers::Worker; use crate::workers::Worker;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -720,15 +722,15 @@ mod tests {
} }
/// `(worker, tokens already held, published KV capacity)`. /// `(worker, tokens already held, published KV capacity)`.
fn snapshot(entries: &[(&Arc<Worker>, u64, u64)]) -> EngineLoadSnapshot { fn snapshot(entries: &[(&Arc<Worker>, u64, u64)]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
7, 7,
entries entries
.iter() .iter()
.map(|(worker, used, capacity)| { .map(|(worker, used, capacity)| {
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: 0, num_running_reqs: 0,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_waiting_uncached_tokens: 0, num_waiting_uncached_tokens: 0,
@@ -749,15 +751,15 @@ mod tests {
/// `(worker, waiting requests, tokens already held, published KV /// `(worker, waiting requests, tokens already held, published KV
/// capacity)`. The queue gate reads `num_waiting_reqs`, which the plain /// capacity)`. The queue gate reads `num_waiting_reqs`, which the plain
/// [`snapshot`] fixture pins at zero. /// [`snapshot`] fixture pins at zero.
fn queued_snapshot(entries: &[(&Arc<Worker>, u64, u64, u64)]) -> EngineLoadSnapshot { fn queued_snapshot(entries: &[(&Arc<Worker>, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
7, 7,
entries entries
.iter() .iter()
.map(|(worker, waiting, used, capacity)| { .map(|(worker, waiting, used, capacity)| {
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: 1, num_running_reqs: 1,
num_waiting_reqs: *waiting, num_waiting_reqs: *waiting,
num_waiting_uncached_tokens: *waiting, num_waiting_uncached_tokens: *waiting,
@@ -811,7 +813,7 @@ mod tests {
metrics: &'a MetricsRegistry, metrics: &'a MetricsRegistry,
model_id: &'a ModelId, model_id: &'a ModelId,
workers: &'a [Arc<Worker>], workers: &'a [Arc<Worker>],
load_snapshot: Option<&'a EngineLoadSnapshot>, load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
request_input_tokens: u64, request_input_tokens: u64,
) -> PrefillSelectionInputs<'a> { ) -> PrefillSelectionInputs<'a> {
PrefillSelectionInputs { PrefillSelectionInputs {
@@ -841,7 +843,7 @@ mod tests {
bucket_selector: &'a BucketSelector, bucket_selector: &'a BucketSelector,
model_id: &'a ModelId, model_id: &'a ModelId,
decode_workers: &'a [Arc<Worker>], decode_workers: &'a [Arc<Worker>],
load_snapshot: Option<&'a EngineLoadSnapshot>, load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
request_input_tokens: u64, request_input_tokens: u64,
) -> DecodeSelectionInputs<'a> { ) -> DecodeSelectionInputs<'a> {
DecodeSelectionInputs { DecodeSelectionInputs {
@@ -5,87 +5,43 @@
use crate::config::{AffinityConfig, SessionAffinityMode}; use crate::config::{AffinityConfig, SessionAffinityMode};
use crate::discovery::WorkerId; use crate::discovery::WorkerId;
use crate::policies::active_load::{spawn_sweeper, Clock, JanitorHandle, SystemTimeClock};
use crate::policies::admission::compare_prefill_pressure; use crate::policies::admission::compare_prefill_pressure;
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy; use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
use crate::policies::{GuardHints, Policy, ProposalKind, SelectionContext, SelectionProposal}; use crate::policies::{GuardHints, Policy, ProposalKind, SelectionContext, SelectionProposal};
use crate::state::load_monitor::router_inflight_load::JanitorHandle;
use crate::state::AffinityStore;
use crate::workers::Worker; use crate::workers::Worker;
use dashmap::DashMap;
use rand::Rng; use rand::Rng;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::Duration;
#[derive(Debug)]
struct Assignment {
worker_id: WorkerId,
last_seen: Instant,
}
#[derive(Debug)]
struct SessionState {
assignments: DashMap<String, Assignment>,
clock: Arc<dyn Clock>,
idle: Duration,
}
impl SessionState {
fn sweep_expired(&self) -> usize {
let now = self.clock.now();
let mut removed = 0;
self.assignments.retain(|_, assignment| {
let keep = now.saturating_duration_since(assignment.last_seen) <= self.idle;
if !keep {
removed += 1;
}
keep
});
removed
}
}
pub struct SessionAwarePolicy { pub struct SessionAwarePolicy {
state: Arc<SessionState>, store: Arc<AffinityStore>,
config: AffinityConfig, config: AffinityConfig,
_janitor: Option<JanitorHandle>, _janitor: Option<JanitorHandle>,
} }
impl SessionAwarePolicy { impl SessionAwarePolicy {
pub fn new(config: AffinityConfig) -> Self { pub fn new(config: AffinityConfig) -> Self {
let state = Arc::new(SessionState { let store = AffinityStore::new(Duration::from_secs(config.session_idle_secs));
assignments: DashMap::new(), let _janitor =
clock: Arc::new(SystemTimeClock), store.spawn_sweeper(Duration::from_secs(config.session_eviction_interval_secs));
idle: Duration::from_secs(config.session_idle_secs),
});
let _janitor = if tokio::runtime::Handle::try_current().is_ok() {
let swept = Arc::clone(&state);
Some(spawn_sweeper(
move || swept.sweep_expired(),
Duration::from_secs(config.session_eviction_interval_secs),
"session-affinity-eviction",
))
} else {
tracing::debug!(
"SessionAwarePolicy constructed outside a Tokio runtime; idle eviction is disabled"
);
None
};
Self { Self {
state, store,
config, config,
_janitor, _janitor,
} }
} }
#[cfg(test)] #[cfg(test)]
fn with_clock(config: AffinityConfig, clock: Arc<dyn Clock>) -> Self { fn with_clock(
config: AffinityConfig,
clock: Arc<dyn crate::state::load_monitor::router_inflight_load::Clock>,
) -> Self {
Self { Self {
state: Arc::new(SessionState { store: AffinityStore::with_clock(Duration::from_secs(config.session_idle_secs), clock),
assignments: DashMap::new(),
clock,
idle: Duration::from_secs(config.session_idle_secs),
}),
config, config,
_janitor: None, _janitor: None,
} }
@@ -93,12 +49,12 @@ impl SessionAwarePolicy {
#[cfg(test)] #[cfg(test)]
fn sweep_expired(&self) -> usize { fn sweep_expired(&self) -> usize {
self.state.sweep_expired() self.store.sweep_expired()
} }
#[cfg(test)] #[cfg(test)]
fn assignment_count(&self) -> usize { fn assignment_count(&self) -> usize {
self.state.assignments.len() self.store.len()
} }
fn assignment_key(&self, session_id: &str, ctx: &SelectionContext<'_>) -> String { fn assignment_key(&self, session_id: &str, ctx: &SelectionContext<'_>) -> String {
@@ -173,18 +129,8 @@ impl Policy for SessionAwarePolicy {
}; };
let assignment_key = self.assignment_key(session_id, ctx); let assignment_key = self.assignment_key(session_id, ctx);
let assigned = self if let Some(primary) = self.store.bound(&assignment_key, workers) {
.state return Some(self.affinity_proposal(Arc::clone(primary), workers, ctx, session_id));
.assignments
.get_mut(&assignment_key)
.map(|mut assignment| {
assignment.last_seen = self.state.clock.now();
assignment.worker_id.clone()
});
if let Some(assigned) = assigned {
if let Some(primary) = workers.iter().find(|worker| worker.id == assigned).cloned() {
return Some(self.affinity_proposal(primary, workers, ctx, session_id));
}
} }
// Persist new assignments only after selecting the final prefill worker. // Persist new assignments only after selecting the final prefill worker.
@@ -203,12 +149,11 @@ impl Policy for SessionAwarePolicy {
let Some(session_id) = ctx.session_id().filter(|id| !id.is_empty()) else { let Some(session_id) = ctx.session_id().filter(|id| !id.is_empty()) else {
return; return;
}; };
self.state.assignments.insert( // Overwrites any previous binding for the key.
self.store.bind(
self.assignment_key(session_id, ctx), self.assignment_key(session_id, ctx),
Assignment { selected,
worker_id: selected.id.clone(), std::slice::from_ref(selected),
last_seen: self.state.clock.now(),
},
); );
} }
@@ -225,7 +170,7 @@ impl std::fmt::Debug for SessionAwarePolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionAwarePolicy") f.debug_struct("SessionAwarePolicy")
.field("config", &self.config) .field("config", &self.config)
.field("assignments", &self.state.assignments.len()) .field("assignments", &self.store.len())
.finish_non_exhaustive() .finish_non_exhaustive()
} }
} }
@@ -308,7 +253,7 @@ fn stable_backup(
mod lifecycle_tests { mod lifecycle_tests {
use super::*; use super::*;
use crate::discovery::{ModelId, WorkerMode, WorkerSpec}; use crate::discovery::{ModelId, WorkerMode, WorkerSpec};
use crate::policies::active_load::MockClock; use crate::state::load_monitor::router_inflight_load::MockClock;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
+67 -151
View File
@@ -17,11 +17,11 @@
//! - **New key, or pinned worker unhealthy** → pick a worker via `fallback` //! - **New key, or pinned worker unhealthy** → pick a worker via `fallback`
//! and record the assignment. //! and record the assignment.
//! //!
//! Worker identity is the worker URL (stable across discovery events). //! Worker identity is the worker ID supplied by discovery.
//! //!
//! # Eviction //! # Eviction
//! A background sweeper (shared engine with the active-load janitor, see //! A background sweeper (shared engine with the active-load janitor, see
//! [`super::active_load::spawn_sweeper`]) removes assignments idle longer //! [`crate::state::load_monitor::router_inflight_load::spawn_sweeper`]) removes assignments idle longer
//! than `idle`, bounding the map against unbounded routing-key cardinality. //! than `idle`, bounding the map against unbounded routing-key cardinality.
//! The sweeper is spawned only when constructed inside a Tokio runtime; //! The sweeper is spawned only when constructed inside a Tokio runtime;
//! unit tests use [`StickyPolicy::with_clock`] and drive eviction //! unit tests use [`StickyPolicy::with_clock`] and drive eviction
@@ -34,49 +34,60 @@
//! is intentionally out of scope here. //! is intentionally out of scope here.
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant}; use std::time::Duration;
use dashmap::DashMap;
use crate::policies::active_load::{spawn_sweeper, Clock, JanitorHandle, SystemTimeClock};
use crate::policies::{Policy, SelectionContext}; use crate::policies::{Policy, SelectionContext};
use crate::server::metrics::{MetricsRegistry, StickyOutcome}; use crate::server::metrics::{MetricsRegistry, StickyOutcome};
use crate::state::load_monitor::router_inflight_load::JanitorHandle;
use crate::state::AffinityStore;
use crate::workers::Worker; use crate::workers::Worker;
/// One routing-key → worker pin, with the last time it was referenced (used /// Sticky-session policy. See the module docs for behavior and limitations.
/// by the idle-eviction sweep). pub struct StickyPolicy {
#[derive(Debug)] store: Arc<AffinityStore>,
struct Assignment { /// Selector for keyless requests and for the initial pin of a new key.
worker_url: String, fallback: Arc<dyn Policy>,
last_seen: Instant, /// Set once via `Policy::attach_metrics`; recording is a no-op until then.
}
/// Shared inner state. Held behind an `Arc` so the background sweeper can
/// reference the same map the `select` hot path mutates.
#[derive(Debug)]
struct StickyState {
assignments: DashMap<String, Assignment>,
clock: Arc<dyn Clock>,
idle: Duration,
/// Metrics sink. Set once via the `Policy::attach_metrics` hook
/// (production) — `None` until then, in which case recording is a no-op.
metrics: OnceLock<Arc<MetricsRegistry>>, metrics: OnceLock<Arc<MetricsRegistry>>,
/// Background idle-eviction sweeper; `None` outside a Tokio runtime.
_janitor: Option<JanitorHandle>,
} }
impl StickyState { impl StickyPolicy {
/// Remove every assignment idle longer than `idle`. Returns the count pub fn new(idle: Duration, eviction_interval: Duration, fallback: Arc<dyn Policy>) -> Self {
/// removed. Called on a fixed cadence by the background sweeper. let store = AffinityStore::new(idle);
let _janitor = store.spawn_sweeper(eviction_interval);
Self {
store,
fallback,
metrics: OnceLock::new(),
_janitor,
}
}
/// Test constructor: injectable clock, no background sweeper.
#[cfg(test)]
fn with_clock(
idle: Duration,
fallback: Arc<dyn Policy>,
clock: Arc<dyn crate::state::load_monitor::router_inflight_load::Clock>,
) -> Self {
Self {
store: AffinityStore::with_clock(idle, clock),
fallback,
metrics: OnceLock::new(),
_janitor: None,
}
}
#[cfg(test)]
fn sweep_expired(&self) -> usize { fn sweep_expired(&self) -> usize {
let now = self.clock.now(); self.store.sweep_expired()
let mut removed = 0; }
self.assignments.retain(|_key, a| {
let keep = now.saturating_duration_since(a.last_seen) <= self.idle; #[cfg(test)]
if !keep { fn assignment_count(&self) -> usize {
removed += 1; self.store.len()
}
keep
});
removed
} }
fn record(&self, outcome: StickyOutcome) { fn record(&self, outcome: StickyOutcome) {
@@ -86,122 +97,21 @@ impl StickyState {
} }
} }
/// Sticky-session policy. See the module docs for behavior and limitations.
pub struct StickyPolicy {
state: Arc<StickyState>,
/// Selector for keyless requests and for the initial pin of a new key.
fallback: Arc<dyn Policy>,
/// Background idle-eviction sweeper. `None` when constructed outside a
/// Tokio runtime (unit tests). Dropping it cancels the task, so the
/// sweeper lives exactly as long as the policy.
_janitor: Option<JanitorHandle>,
}
impl StickyPolicy {
/// Production constructor: monotonic `SystemTimeClock`, with a
/// background eviction sweeper spawned on `eviction_interval` cadence
/// (only if called inside a Tokio runtime — the factory runs inside
/// `main`'s runtime).
pub fn new(idle: Duration, eviction_interval: Duration, fallback: Arc<dyn Policy>) -> Self {
let state = Arc::new(StickyState {
assignments: DashMap::new(),
clock: Arc::new(SystemTimeClock),
idle,
metrics: OnceLock::new(),
});
// `spawn_sweeper` needs a runtime; the factory builds policies inside
// `main`'s Tokio runtime. Guard so sync constructions (e.g. the
// factory's `build_policy_kind_only` test helper) don't panic.
let _janitor = if tokio::runtime::Handle::try_current().is_ok() {
let swept = Arc::clone(&state);
Some(spawn_sweeper(
move || swept.sweep_expired(),
eviction_interval,
"sticky-eviction",
))
} else {
// Only reached by sync construction (test helpers). In production
// the factory builds policies inside `main`'s runtime, so the
// sweeper always spawns. Log it so a future off-runtime
// construction that silently disables eviction is greppable.
tracing::debug!(
"StickyPolicy constructed outside a Tokio runtime; idle eviction is disabled"
);
None
};
Self {
state,
fallback,
_janitor,
}
}
/// Test constructor: injectable clock, no background sweeper. Tests
/// advance a `MockClock` and call [`Self::sweep_expired`] directly for
/// deterministic eviction coverage.
#[cfg(test)]
fn with_clock(idle: Duration, fallback: Arc<dyn Policy>, clock: Arc<dyn Clock>) -> Self {
Self {
state: Arc::new(StickyState {
assignments: DashMap::new(),
clock,
idle,
metrics: OnceLock::new(),
}),
fallback,
_janitor: None,
}
}
#[cfg(test)]
fn sweep_expired(&self) -> usize {
self.state.sweep_expired()
}
#[cfg(test)]
fn assignment_count(&self) -> usize {
self.state.assignments.len()
}
}
impl Policy for StickyPolicy { impl Policy for StickyPolicy {
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> { fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
let Some(key) = ctx.routing_key().filter(|k| !k.is_empty()) else { let Some(key) = ctx.routing_key().filter(|k| !k.is_empty()) else {
self.state.record(StickyOutcome::NoRoutingKey); self.record(StickyOutcome::NoRoutingKey);
return self.fallback.select(workers, ctx); return self.fallback.select(workers, ctx);
}; };
if let Some(worker) = self.store.bound(key, workers) {
// Fast path: an existing pin whose worker is still in the healthy set. self.record(StickyOutcome::Hit);
let mut existing = false; return Some(Arc::clone(worker));
if let Some(mut entry) = self.state.assignments.get_mut(key) {
existing = true;
if let Some(worker) = workers.iter().find(|w| w.url == entry.worker_url).cloned() {
entry.last_seen = self.state.clock.now();
drop(entry); // release the shard lock before recording
self.state.record(StickyOutcome::Hit);
return Some(worker);
}
// Pinned worker is no longer healthy — fall through to reassign.
drop(entry);
} }
// Vacant key, or the pinned worker dropped out: (re)pin the fallback's choice.
// Vacant key, or the pinned worker dropped out: (re)assign via the let remap = self.store.contains(key);
// fallback. The read-miss above and this insert are intentionally NOT
// atomic — the shard lock is released before `fallback.select` (which
// may do real work, e.g. `load_based`) so it is never held across an
// unrelated computation. Two requests racing the *same* fresh key may
// therefore both assign (last-writer-wins in the map; both may record
// `Assigned`). The scatter is transient and self-heals: the next
// request for that key hits the surviving pin.
let chosen = self.fallback.select(workers, ctx)?; let chosen = self.fallback.select(workers, ctx)?;
self.state.assignments.insert( let chosen = Arc::clone(self.store.bind(key.to_string(), &chosen, workers));
key.to_string(), self.record(if remap {
Assignment {
worker_url: chosen.url.clone(),
last_seen: self.state.clock.now(),
},
);
self.state.record(if existing {
StickyOutcome::Remap StickyOutcome::Remap
} else { } else {
StickyOutcome::Assigned StickyOutcome::Assigned
@@ -210,7 +120,7 @@ impl Policy for StickyPolicy {
} }
fn attach_metrics(&self, metrics: Arc<MetricsRegistry>) { fn attach_metrics(&self, metrics: Arc<MetricsRegistry>) {
let _ = self.state.metrics.set(metrics); let _ = self.metrics.set(metrics);
} }
fn needs_load_snapshot(&self) -> bool { fn needs_load_snapshot(&self) -> bool {
@@ -226,8 +136,7 @@ impl std::fmt::Debug for StickyPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StickyPolicy") f.debug_struct("StickyPolicy")
.field("fallback", &self.fallback) .field("fallback", &self.fallback)
.field("idle", &self.state.idle) .field("assignments", &self.store.len())
.field("assignments", &self.state.assignments.len())
.finish_non_exhaustive() .finish_non_exhaustive()
} }
} }
@@ -236,6 +145,7 @@ impl std::fmt::Debug for StickyPolicy {
mod tests { mod tests {
use super::*; use super::*;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use std::time::Instant;
#[test] #[test]
fn sticky_propagates_fallback_load_snapshot_capability() { fn sticky_propagates_fallback_load_snapshot_capability() {
@@ -271,7 +181,9 @@ mod tests {
} }
fn policy(idle_secs: u64) -> StickyPolicy { fn policy(idle_secs: u64) -> StickyPolicy {
let clock = Arc::new(crate::policies::active_load::MockClock::new(Instant::now())); let clock = Arc::new(
crate::state::load_monitor::router_inflight_load::MockClock::new(Instant::now()),
);
StickyPolicy::with_clock(Duration::from_secs(idle_secs), fallback(), clock) StickyPolicy::with_clock(Duration::from_secs(idle_secs), fallback(), clock)
} }
@@ -374,7 +286,9 @@ mod tests {
#[test] #[test]
fn sweep_evicts_idle_entries_keeps_fresh() { fn sweep_evicts_idle_entries_keeps_fresh() {
let model = ModelId("tiny".into()); let model = ModelId("tiny".into());
let clock = Arc::new(crate::policies::active_load::MockClock::new(Instant::now())); let clock = Arc::new(
crate::state::load_monitor::router_inflight_load::MockClock::new(Instant::now()),
);
let p = StickyPolicy::with_clock(Duration::from_secs(10), fallback(), clock.clone()); let p = StickyPolicy::with_clock(Duration::from_secs(10), fallback(), clock.clone());
let workers = vec![worker("w0"), worker("w1")]; let workers = vec![worker("w0"), worker("w1")];
@@ -401,7 +315,9 @@ mod tests {
#[test] #[test]
fn hit_refreshes_last_seen_so_active_key_is_not_evicted() { fn hit_refreshes_last_seen_so_active_key_is_not_evicted() {
let model = ModelId("tiny".into()); let model = ModelId("tiny".into());
let clock = Arc::new(crate::policies::active_load::MockClock::new(Instant::now())); let clock = Arc::new(
crate::state::load_monitor::router_inflight_load::MockClock::new(Instant::now()),
);
let p = StickyPolicy::with_clock(Duration::from_secs(10), fallback(), clock.clone()); let p = StickyPolicy::with_clock(Duration::from_secs(10), fallback(), clock.clone());
let workers = vec![worker("w0")]; let workers = vec![worker("w0")];
let ctx = SelectionContext::with_routing_key(&model, None, Some("u1")); let ctx = SelectionContext::with_routing_key(&model, None, Some("u1"));
+1 -1
View File
@@ -262,7 +262,7 @@ impl Proxy {
/// pump task and held for the entire body lifetime (headers → last byte /// pump task and held for the entire body lifetime (headers → last byte
/// / client disconnect). The proxy does not inspect the boxed value; it /// / client disconnect). The proxy does not inspect the boxed value; it
/// relies entirely on `Drop` semantics, so callers typically pack /// relies entirely on `Drop` semantics, so callers typically pack
/// `(LoadGuard, ActiveLoadGuard)` here. This keeps both the per-worker /// `(LoadGuard, RouterInflightLoadGuard)` here. This keeps both the per-worker
/// `active_requests` counter and the per-request active-load entry alive /// `active_requests` counter and the per-request active-load entry alive
/// for the full streaming lifetime — without which a long-running SSE /// for the full streaming lifetime — without which a long-running SSE
/// response would under-report load. /// response would under-report load.
+1 -1
View File
@@ -88,7 +88,7 @@ impl ErrorEventScanner {
/// pump finishes (stream exhausted, client disconnects, or upstream errors). /// pump finishes (stream exhausted, client disconnects, or upstream errors).
/// The opaque `Box<dyn Send + 'static>` accepts any drop-only payload — most /// The opaque `Box<dyn Send + 'static>` accepts any drop-only payload — most
/// commonly a tuple of [`crate::workers::LoadGuard`] and /// commonly a tuple of [`crate::workers::LoadGuard`] and
/// [`crate::policies::active_load::ActiveLoadGuard`]. The proxy does not /// [`crate::state::load_monitor::router_inflight_load::RouterInflightLoadGuard`]. The proxy does not
/// inspect the value; it relies entirely on `Drop` semantics, so callers can /// inspect the value; it relies entirely on `Drop` semantics, so callers can
/// pack arbitrary cleanup state in. Pass `None` for callers that manage the /// pack arbitrary cleanup state in. Pass `None` for callers that manage the
/// guard externally (e.g. non-streaming paths where the handler itself is the /// guard externally (e.g. non-streaming paths where the handler itself is the
@@ -3,15 +3,15 @@
use crate::config::Config; use crate::config::Config;
use crate::policies::active_load::ActiveLoadRegistry;
use crate::policies::buckets::BucketSelector; use crate::policies::buckets::BucketSelector;
use crate::policies::engine_load::EngineLoadTable;
use crate::policies::kv_events::{BlockSizeOracle, KvIndexMetrics};
use crate::policies::prefix_provider::RadixTreePrefixProvider; use crate::policies::prefix_provider::RadixTreePrefixProvider;
use crate::policies::PolicyRegistry; use crate::policies::PolicyRegistry;
use crate::proxy::Proxy; use crate::proxy::Proxy;
use crate::server::inflight::InflightHttp; use crate::server::inflight::InflightHttp;
use crate::server::metrics::MetricsRegistry; use crate::server::metrics::MetricsRegistry;
use crate::state::kv_events::{BlockSizeOracle, KvIndexMetrics};
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadTable;
use crate::state::load_monitor::router_inflight_load::RouterInflightLoadRegistry;
use crate::tokenizer::TokenizerRegistry; use crate::tokenizer::TokenizerRegistry;
use crate::workers::WorkerRegistry; use crate::workers::WorkerRegistry;
use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::atomic::{AtomicU8, Ordering};
@@ -36,24 +36,24 @@ pub struct AppContext {
pub bucket_selector: Arc<BucketSelector>, pub bucket_selector: Arc<BucketSelector>,
/// Per-worker active-load bookkeeping shared by the proxy, policies, /// Per-worker active-load bookkeeping shared by the proxy, policies,
/// timeout janitor, and metrics. /// timeout janitor, and metrics.
pub active_load: Arc<ActiveLoadRegistry>, pub router_inflight_load: Arc<RouterInflightLoadRegistry>,
/// Lightweight Prometheus-format metrics registry served via /// Lightweight Prometheus-format metrics registry served via
/// `/metrics`. Shared with the edge middleware (requests_total / /// `/metrics`. Shared with the edge middleware (requests_total /
/// responses_total), the chat handler (worker_requests_total), the /// responses_total), the chat handler (worker_requests_total), the
/// active-load registry, policy-specific counters, and PD dispatch. /// active-load registry, policy-specific counters, and PD dispatch.
pub metrics: Arc<MetricsRegistry>, pub metrics: Arc<MetricsRegistry>,
/// Shared Engine LoadStat table; ingress captures one immutable snapshot per request. /// Shared Engine LoadStat table; ingress captures one immutable snapshot per request.
pub engine_load: Arc<EngineLoadTable>, pub engine_reported_load: Arc<EngineReportedLoadTable>,
pub prefix_index: Option<Arc<dyn sgl_kv_indexer::PrefixIndex>>, pub prefix_index: Option<Arc<dyn sgl_kv_indexer::PrefixIndex>>,
pub radix_tree_prefix_provider: Option<RadixTreePrefixProvider>, pub radix_tree_prefix_provider: Option<RadixTreePrefixProvider>,
pub block_size_oracle: Arc<BlockSizeOracle>, pub block_size_oracle: Arc<BlockSizeOracle>,
/// Read-only handles `/metrics` pulls the KV storage-tier series from on /// Read-only handles `/metrics` pulls the KV storage-tier series from on
/// scrape. `None` when this router maintains no local tree (external /// scrape. `None` when this router maintains no local tree (external
/// Indexer), where those series would all be a structural zero — see /// Indexer), where those series would all be a structural zero — see
/// [`crate::policies::kv_events::KvEventIndex::metrics_source`]. /// [`crate::state::kv_events::KvEventIndex::metrics_source`].
pub kv_metrics: Option<KvIndexMetrics>, pub kv_metrics: Option<KvIndexMetrics>,
/// Open HTTP exchanges, on every route. What axum's graceful shutdown /// Open HTTP exchanges, on every route. What axum's graceful shutdown
/// is actually waiting on during the drain — `active_load` sees only the /// is actually waiting on during the drain — `router_inflight_load` sees only the
/// proxied subset. /// proxied subset.
pub inflight_http: Arc<InflightHttp>, pub inflight_http: Arc<InflightHttp>,
readiness: AtomicU8, readiness: AtomicU8,
@@ -67,34 +67,34 @@ impl AppContext {
registry: Arc<WorkerRegistry>, registry: Arc<WorkerRegistry>,
policies: Arc<PolicyRegistry>, policies: Arc<PolicyRegistry>,
) -> Self { ) -> Self {
Self::with_active_load( Self::with_router_inflight_load(
config, config,
tokenizers, tokenizers,
proxy, proxy,
registry, registry,
policies, policies,
ActiveLoadRegistry::with_defaults(), RouterInflightLoadRegistry::with_defaults(),
) )
} }
/// Construct an [`AppContext`] with an explicit [`ActiveLoadRegistry`]. /// Construct an [`AppContext`] with an explicit [`RouterInflightLoadRegistry`].
/// Production wires the default (5-minute timeout, SystemTimeClock) /// Production wires the default (5-minute timeout, SystemTimeClock)
/// via [`Self::new`]; tests that exercise the janitor pass a registry /// via [`Self::new`]; tests that exercise the janitor pass a registry
/// built with a `MockClock`. /// built with a `MockClock`.
pub fn with_active_load( pub fn with_router_inflight_load(
config: Config, config: Config,
tokenizers: Arc<TokenizerRegistry>, tokenizers: Arc<TokenizerRegistry>,
proxy: Arc<Proxy>, proxy: Arc<Proxy>,
registry: Arc<WorkerRegistry>, registry: Arc<WorkerRegistry>,
policies: Arc<PolicyRegistry>, policies: Arc<PolicyRegistry>,
active_load: Arc<ActiveLoadRegistry>, router_inflight_load: Arc<RouterInflightLoadRegistry>,
) -> Self { ) -> Self {
let metrics = MetricsRegistry::new(); let metrics = MetricsRegistry::new();
// Wire the per-worker active-load gauge so `sgl_router_active_load` // Wire the per-worker active-load gauge so `sgl_router_active_load`
// mirrors the live counter on every register / drop / sweep. // mirrors the live counter on every register / drop / sweep.
// Without this, the metric is permanently 0 in production even // Without this, the metric is permanently 0 in production even
// though the chat handler is faithfully calling `register`. // though the chat handler is faithfully calling `register`.
active_load.attach_metrics(Arc::clone(&metrics)); router_inflight_load.attach_metrics(Arc::clone(&metrics));
// The metrics registry is built after the policy registry, so attach // The metrics registry is built after the policy registry, so attach
// it here for policies that emit their own counters. // it here for policies that emit their own counters.
policies.attach_metrics(Arc::clone(&metrics)); policies.attach_metrics(Arc::clone(&metrics));
@@ -106,13 +106,13 @@ impl AppContext {
registry, registry,
policies, policies,
bucket_selector, bucket_selector,
active_load, router_inflight_load,
metrics, metrics,
prefix_index: None, prefix_index: None,
radix_tree_prefix_provider: None, radix_tree_prefix_provider: None,
block_size_oracle: BlockSizeOracle::new(), block_size_oracle: BlockSizeOracle::new(),
kv_metrics: None, kv_metrics: None,
engine_load: EngineLoadTable::new(), engine_reported_load: EngineReportedLoadTable::new(),
inflight_http: InflightHttp::new(), inflight_http: InflightHttp::new(),
readiness: AtomicU8::new(READINESS_NOT_READY), readiness: AtomicU8::new(READINESS_NOT_READY),
} }
@@ -188,20 +188,20 @@ impl AppContext {
}, },
), ),
proxy: crate::config::ProxyConfig::default(), proxy: crate::config::ProxyConfig::default(),
active_load: crate::config::ActiveLoadConfig::default(), router_inflight_load: crate::config::InflightLoadConfig::default(),
}, },
tokenizers: Arc::new(TokenizerRegistry::default()), tokenizers: Arc::new(TokenizerRegistry::default()),
proxy: Arc::new(Proxy::new(std::time::Duration::from_secs(60)).expect("stub proxy")), proxy: Arc::new(Proxy::new(std::time::Duration::from_secs(60)).expect("stub proxy")),
registry: Arc::new(WorkerRegistry::default()), registry: Arc::new(WorkerRegistry::default()),
policies: Arc::new(PolicyRegistry::default()), policies: Arc::new(PolicyRegistry::default()),
bucket_selector: Arc::new(BucketSelector::new(None)), bucket_selector: Arc::new(BucketSelector::new(None)),
active_load: ActiveLoadRegistry::with_defaults(), router_inflight_load: RouterInflightLoadRegistry::with_defaults(),
metrics: MetricsRegistry::new(), metrics: MetricsRegistry::new(),
prefix_index: None, prefix_index: None,
radix_tree_prefix_provider: None, radix_tree_prefix_provider: None,
block_size_oracle: BlockSizeOracle::new(), block_size_oracle: BlockSizeOracle::new(),
kv_metrics: None, kv_metrics: None,
engine_load: EngineLoadTable::new(), engine_reported_load: EngineReportedLoadTable::new(),
inflight_http: InflightHttp::new(), inflight_http: InflightHttp::new(),
readiness: AtomicU8::new(READINESS_NOT_READY), readiness: AtomicU8::new(READINESS_NOT_READY),
} }
@@ -3,7 +3,7 @@
//! In-flight HTTP accounting, for the termination drain to report on. //! In-flight HTTP accounting, for the termination drain to report on.
//! //!
//! [`ActiveLoadRegistry`](crate::policies::active_load::ActiveLoadRegistry) //! [`RouterInflightLoadRegistry`](crate::state::load_monitor::router_inflight_load::RouterInflightLoadRegistry)
//! counts *proxied* requests — what the workers are busy with. Axum's graceful //! counts *proxied* requests — what the workers are busy with. Axum's graceful
//! shutdown waits on something different and larger: every HTTP exchange still //! shutdown waits on something different and larger: every HTTP exchange still
//! open on an accepted connection, on any route, until its response body has //! open on an accepted connection, on any route, until its response body has
+18 -13
View File
@@ -375,12 +375,12 @@ impl PolicySelectionFailureReason {
/// Active-load kind label — separates the two axes of per-worker load. /// Active-load kind label — separates the two axes of per-worker load.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum ActiveLoadKind { pub enum RouterInflightLoadKind {
PrefillTokens, PrefillTokens,
DecodeBlocks, DecodeBlocks,
} }
impl ActiveLoadKind { impl RouterInflightLoadKind {
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { match self {
Self::PrefillTokens => "prefill_tokens", Self::PrefillTokens => "prefill_tokens",
@@ -408,7 +408,7 @@ pub struct MetricsRegistry {
request_duration: Mutex<HashMap<String, Histogram>>, request_duration: Mutex<HashMap<String, Histogram>>,
ttft_seconds: Mutex<HashMap<String, Histogram>>, ttft_seconds: Mutex<HashMap<String, Histogram>>,
stream_outcome_total: Mutex<HashMap<StreamOutcomeKey, Arc<AtomicU64>>>, stream_outcome_total: Mutex<HashMap<StreamOutcomeKey, Arc<AtomicU64>>>,
active_load: Mutex<HashMap<ActiveLoadKey, Arc<AtomicI64>>>, router_inflight_load: Mutex<HashMap<RouterInflightLoadKey, Arc<AtomicI64>>>,
stale_requests_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>, stale_requests_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
decode_affinity_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>, decode_affinity_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
sticky_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>, sticky_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
@@ -470,12 +470,12 @@ pub struct WorkerSnapshot {
pub healthy: bool, pub healthy: bool,
/// Circuit breaker state code: 0=closed, 1=open, 2=half_open. /// Circuit breaker state code: 0=closed, 1=open, 2=half_open.
pub cb_state: u8, pub cb_state: u8,
/// In-flight request count for this worker (`Worker::active_load`). /// In-flight request count for this worker (`Worker::router_inflight_load`).
pub inflight: i64, pub inflight: i64,
} }
#[derive(Debug, Hash, Eq, PartialEq, Clone)] #[derive(Debug, Hash, Eq, PartialEq, Clone)]
struct ActiveLoadKey { struct RouterInflightLoadKey {
worker_url: String, worker_url: String,
kind: &'static str, kind: &'static str,
} }
@@ -657,12 +657,17 @@ impl MetricsRegistry {
/// Set `sgl_router_active_load` for the given worker + kind. Replaces the /// Set `sgl_router_active_load` for the given worker + kind. Replaces the
/// previous value (gauge semantics). /// previous value (gauge semantics).
pub fn set_active_load(&self, worker_url: &str, kind: ActiveLoadKind, value: i64) { pub fn set_router_inflight_load(
let key = ActiveLoadKey { &self,
worker_url: &str,
kind: RouterInflightLoadKind,
value: i64,
) {
let key = RouterInflightLoadKey {
worker_url: worker_url.to_owned(), worker_url: worker_url.to_owned(),
kind: kind.as_str(), kind: kind.as_str(),
}; };
let mut guard = self.active_load.lock(); let mut guard = self.router_inflight_load.lock();
let gauge = guard let gauge = guard
.entry(key) .entry(key)
.or_insert_with(|| Arc::new(AtomicI64::new(0))) .or_insert_with(|| Arc::new(AtomicI64::new(0)))
@@ -984,13 +989,13 @@ impl MetricsRegistry {
} }
drop(guard); drop(guard);
// active_load gauge // router_inflight_load gauge
out.push_str( out.push_str(
"# HELP sgl_router_active_load Per-worker active load (prefill_tokens or decode_blocks).\n", "# HELP sgl_router_active_load Per-worker active load (prefill_tokens or decode_blocks).\n",
); );
out.push_str("# TYPE sgl_router_active_load gauge\n"); out.push_str("# TYPE sgl_router_active_load gauge\n");
let guard = self.active_load.lock(); let guard = self.router_inflight_load.lock();
let mut entries: Vec<(&ActiveLoadKey, i64)> = guard let mut entries: Vec<(&RouterInflightLoadKey, i64)> = guard
.iter() .iter()
.map(|(k, v)| (k, v.load(Ordering::Relaxed))) .map(|(k, v)| (k, v.load(Ordering::Relaxed)))
.collect(); .collect();
@@ -1651,8 +1656,8 @@ mod tests {
#[test] #[test]
fn set_active_load_gauge_overwrites() { fn set_active_load_gauge_overwrites() {
let reg = MetricsRegistry::new(); let reg = MetricsRegistry::new();
reg.set_active_load("http://w:30000", ActiveLoadKind::PrefillTokens, 100); reg.set_router_inflight_load("http://w:30000", RouterInflightLoadKind::PrefillTokens, 100);
reg.set_active_load("http://w:30000", ActiveLoadKind::PrefillTokens, 250); reg.set_router_inflight_load("http://w:30000", RouterInflightLoadKind::PrefillTokens, 250);
let out = reg.render(); let out = reg.render();
assert!(out.contains( assert!(out.contains(
r#"sgl_router_active_load{worker_url="http://w:30000",kind="prefill_tokens"} 250"#, r#"sgl_router_active_load{worker_url="http://w:30000",kind="prefill_tokens"} 250"#,
@@ -6,8 +6,6 @@ mod preparation;
use crate::config::{SessionAffinityMode, DEFAULT_MIN_LOAD_CHOICES}; use crate::config::{SessionAffinityMode, DEFAULT_MIN_LOAD_CHOICES};
use crate::discovery::{ModelId, WorkerMode}; use crate::discovery::{ModelId, WorkerMode};
use crate::policies::engine_load::EngineLoadSnapshot;
use crate::policies::kv_events::{compute_block_hashes, compute_block_hashes_bigram};
use crate::policies::registry::{PdPoolResolver, PdResolveError}; use crate::policies::registry::{PdPoolResolver, PdResolveError};
use crate::policies::selection::{ use crate::policies::selection::{
select_decode_peer, select_prefill_worker, DecodeSelectionInputs, PrefillSelectionInputs, select_decode_peer, select_prefill_worker, DecodeSelectionInputs, PrefillSelectionInputs,
@@ -16,6 +14,8 @@ use crate::policies::{ExternalPrefixSignal, Policy};
use crate::server::app_context::AppContext; use crate::server::app_context::AppContext;
use crate::server::error::ApiError; use crate::server::error::ApiError;
use crate::server::metrics::PolicySelectionFailureReason; use crate::server::metrics::PolicySelectionFailureReason;
use crate::state::kv_events::{compute_block_hashes, compute_block_hashes_bigram};
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
use crate::workers::Worker; use crate::workers::Worker;
use axum::body::Body; use axum::body::Body;
use axum::extract::State; use axum::extract::State;
@@ -113,17 +113,17 @@ fn capture_load_snapshot(
ctx: &AppContext, ctx: &AppContext,
policy: &dyn Policy, policy: &dyn Policy,
candidates: &[Arc<Worker>], candidates: &[Arc<Worker>],
) -> Option<EngineLoadSnapshot> { ) -> Option<EngineReportedLoadSnapshot> {
let needed = policy.needs_load_snapshot() let needed = policy.needs_load_snapshot()
|| candidates || candidates
.iter() .iter()
.any(|worker| worker.mode() == WorkerMode::Prefill); .any(|worker| worker.mode() == WorkerMode::Prefill);
needed.then(|| ctx.engine_load.capture_snapshot(Instant::now())) needed.then(|| ctx.engine_reported_load.capture_snapshot(Instant::now()))
} }
struct RoutingContext<'a> { struct RoutingContext<'a> {
prefix_matches: Option<ExternalPrefixSignal>, prefix_matches: Option<ExternalPrefixSignal>,
load_snapshot: Option<EngineLoadSnapshot>, load_snapshot: Option<EngineReportedLoadSnapshot>,
ttft_slo_ms: Option<u64>, ttft_slo_ms: Option<u64>,
tps_slo: Option<f64>, tps_slo: Option<f64>,
routing_key: Option<&'a str>, routing_key: Option<&'a str>,
@@ -5,7 +5,6 @@
use super::preparation::{generate_room_id, BootstrapFields, PreparedChatRequest}; use super::preparation::{generate_room_id, BootstrapFields, PreparedChatRequest};
use crate::discovery::WorkerMode; use crate::discovery::WorkerMode;
use crate::policies::active_load::ActiveLoadGuard;
use crate::proxy::sse::StreamEnd; use crate::proxy::sse::StreamEnd;
use crate::server::app_context::AppContext; use crate::server::app_context::AppContext;
use crate::server::error::ApiError; use crate::server::error::ApiError;
@@ -13,6 +12,7 @@ use crate::server::metrics::{
classify_stream_end, outcome_from_status, MetricsRegistry, RequestLogContext, RequestOutcome, classify_stream_end, outcome_from_status, MetricsRegistry, RequestLogContext, RequestOutcome,
StaleRequestOutcome, WorkerModeLabel, StaleRequestOutcome, WorkerModeLabel,
}; };
use crate::state::load_monitor::router_inflight_load::RouterInflightLoadGuard;
use crate::workers::{LoadGuard, Worker}; use crate::workers::{LoadGuard, Worker};
use axum::body::Body; use axum::body::Body;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response}; use axum::http::{HeaderMap, HeaderName, HeaderValue, Response};
@@ -24,7 +24,7 @@ use std::time::Instant;
const CHAT_PATH: &str = "/v1/chat/completions"; const CHAT_PATH: &str = "/v1/chat/completions";
// Expose the selected decode worker to both PD workers and the client. // Expose the selected decode worker to both PD workers and the client.
const X_SGL_DECODE_URL: HeaderName = HeaderName::from_static("x-sgl-decode-url"); const X_SGL_DECODE_URL: HeaderName = HeaderName::from_static("x-sgl-decode-url");
type LoadGuards = (LoadGuard, ActiveLoadGuard); type LoadGuards = (LoadGuard, RouterInflightLoadGuard);
/// A plain worker, or a prefill worker paired with a decode worker for PD. /// A plain worker, or a prefill worker paired with a decode worker for PD.
pub(super) struct SelectedWorkers { pub(super) struct SelectedWorkers {
@@ -58,7 +58,7 @@ pub(super) async fn forward_chat_request(
} else { } else {
prefill.load_guard() prefill.load_guard()
}; };
let active_request_guard = ctx.active_load.register( let active_request_guard = ctx.router_inflight_load.register(
prefill.id.clone(), prefill.id.clone(),
prefill.url.clone(), prefill.url.clone(),
request.input_token_count, request.input_token_count,
@@ -97,7 +97,7 @@ pub(super) async fn forward_chat_request(
); );
let decode_load_guards = ( let decode_load_guards = (
decode.load_guard(), decode.load_guard(),
ctx.active_load ctx.router_inflight_load
.register(decode.id.clone(), decode.url.clone(), 0, 1), .register(decode.id.clone(), decode.url.clone(), 0, 1),
); );
(decode, decode_load_guards) (decode, decode_load_guards)
@@ -10,9 +10,9 @@
//! discovered" failure mode is observable. //! discovered" failure mode is observable.
use crate::discovery::WorkerMode; use crate::discovery::WorkerMode;
use crate::policies::kv_events::{KvIndexMetrics, Tiers, ACCOUNTING_REASONS};
use crate::server::app_context::AppContext; use crate::server::app_context::AppContext;
use crate::server::metrics::{escape_label, WorkerSnapshot}; use crate::server::metrics::{escape_label, WorkerSnapshot};
use crate::state::kv_events::{KvIndexMetrics, Tiers, ACCOUNTING_REASONS};
use axum::extract::State; use axum::extract::State;
use axum::http::header::CONTENT_TYPE; use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode; use axum::http::StatusCode;
@@ -46,7 +46,7 @@ pub async fn metrics(State(ctx): State<Arc<AppContext>>) -> impl IntoResponse {
// Saturating rather than `as i64`: a guard-accounting // Saturating rather than `as i64`: a guard-accounting
// underflow would wrap usize and render as a nonsensical // underflow would wrap usize and render as a nonsensical
// negative gauge; clamp to a large positive ceiling instead. // negative gauge; clamp to a large positive ceiling instead.
inflight: i64::try_from(w.active_load()).unwrap_or(i64::MAX), inflight: i64::try_from(w.router_inflight_load()).unwrap_or(i64::MAX),
} }
}) })
.collect(); .collect();
@@ -188,7 +188,7 @@ mod tests {
/// cell of the tally. /// cell of the tally.
#[tokio::test] #[tokio::test]
async fn kv_tier_series_render_per_worker_and_per_medium() { async fn kv_tier_series_render_per_worker_and_per_medium() {
use crate::policies::kv_events::{EventKind, EventTally, HashTree, KvWorkerId}; use crate::state::kv_events::{EventKind, EventTally, HashTree, KvWorkerId};
let kv = KvIndexMetrics::new(Arc::new(HashTree::new()), Arc::new(EventTally::new())); let kv = KvIndexMetrics::new(Arc::new(HashTree::new()), Arc::new(EventTally::new()));
let w = KvWorkerId::new("http://w0:30000".into(), 0); let w = KvWorkerId::new("http://w0:30000".into(), 0);
@@ -231,7 +231,7 @@ mod tests {
/// calls `clear_worker`. /// calls `clear_worker`.
#[tokio::test] #[tokio::test]
async fn kv_tree_blocks_drop_with_the_worker() { async fn kv_tree_blocks_drop_with_the_worker() {
use crate::policies::kv_events::{EventTally, HashTree, KvWorkerId}; use crate::state::kv_events::{EventTally, HashTree, KvWorkerId};
let kv = KvIndexMetrics::new(Arc::new(HashTree::new()), Arc::new(EventTally::new())); let kv = KvIndexMetrics::new(Arc::new(HashTree::new()), Arc::new(EventTally::new()));
let w = KvWorkerId::new("http://w0:30000".into(), 0); let w = KvWorkerId::new("http://w0:30000".into(), 0);
@@ -254,7 +254,7 @@ mod tests {
/// all four families. /// all four families.
#[tokio::test] #[tokio::test]
async fn metrics_endpoint_emits_kv_series_when_a_tree_is_maintained() { async fn metrics_endpoint_emits_kv_series_when_a_tree_is_maintained() {
use crate::policies::kv_events::{EventTally, HashTree, KvWorkerId}; use crate::state::kv_events::{EventTally, HashTree, KvWorkerId};
let mut ctx = AppContext::stub(); let mut ctx = AppContext::stub();
let tree = Arc::new(HashTree::new()); let tree = Arc::new(HashTree::new());
@@ -136,7 +136,7 @@ mod tests {
}, },
), ),
proxy: crate::config::ProxyConfig::default(), proxy: crate::config::ProxyConfig::default(),
active_load: crate::config::ActiveLoadConfig::default(), router_inflight_load: crate::config::InflightLoadConfig::default(),
}; };
let registry = crate::tokenizer::TokenizerRegistry::load_from_config(&cfg).unwrap(); let registry = crate::tokenizer::TokenizerRegistry::load_from_config(&cfg).unwrap();
let proxy = Arc::new( let proxy = Arc::new(
@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Session and routing-key assignments with idle expiry. The store keeps
//! bindings; deciding whether to reuse, create or replace one is the policy's.
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use super::load_monitor::router_inflight_load::{
spawn_sweeper, Clock, JanitorHandle, SystemTimeClock,
};
use crate::discovery::WorkerId;
use crate::workers::Worker;
#[derive(Debug)]
struct Assignment {
engine: WorkerId,
last_seen: Instant,
}
#[derive(Debug)]
pub struct AffinityStore {
assignments: DashMap<String, Assignment>,
clock: Arc<dyn Clock>,
idle: Duration,
}
impl AffinityStore {
pub fn new(idle: Duration) -> Arc<Self> {
Self::with_clock(idle, Arc::new(SystemTimeClock))
}
pub fn with_clock(idle: Duration, clock: Arc<dyn Clock>) -> Arc<Self> {
Arc::new(Self {
assignments: DashMap::new(),
clock,
idle,
})
}
/// The bound engine when it is still among `engines`; refreshes the binding.
pub fn bound<'e>(&self, key: &str, engines: &'e [Arc<Worker>]) -> Option<&'e Arc<Worker>> {
let mut assignment = self.assignments.get_mut(key)?;
let engine = engines
.iter()
.find(|engine| engine.id == assignment.engine)?;
assignment.last_seen = self.clock.now();
Some(engine)
}
pub fn contains(&self, key: &str) -> bool {
self.assignments.contains_key(key)
}
/// The bound engine id, whether or not it is still a candidate.
pub fn binding(&self, key: &str) -> Option<WorkerId> {
self.assignments.get(key).map(|a| a.engine.clone())
}
/// Binds `engine`, unless a concurrent pick already bound another engine
/// that is still in `engines`; that binding wins so racing first touches
/// converge on one engine.
pub fn bind<'e>(
&self,
key: String,
engine: &'e Arc<Worker>,
engines: &'e [Arc<Worker>],
) -> &'e Arc<Worker> {
let now = self.clock.now();
let mut assignment = self.assignments.entry(key).or_insert_with(|| Assignment {
engine: engine.id.clone(),
last_seen: now,
});
match engines.iter().find(|bound| bound.id == assignment.engine) {
Some(bound) => {
assignment.last_seen = now;
bound
}
None => {
assignment.engine = engine.id.clone();
assignment.last_seen = now;
engine
}
}
}
pub fn len(&self) -> usize {
self.assignments.len()
}
pub fn is_empty(&self) -> bool {
self.assignments.is_empty()
}
pub fn sweep_expired(&self) -> usize {
let now = self.clock.now();
let mut removed = 0;
self.assignments.retain(|_, assignment| {
let keep = now.saturating_duration_since(assignment.last_seen) <= self.idle;
if !keep {
removed += 1;
}
keep
});
removed
}
/// Periodic eviction; `None` outside a Tokio runtime.
pub fn spawn_sweeper(self: &Arc<Self>, interval: Duration) -> Option<JanitorHandle> {
let store = Arc::clone(self);
tokio::runtime::Handle::try_current()
.ok()
.map(|_| spawn_sweeper(move || store.sweep_expired(), interval, "affinity-eviction"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{ModelId, WorkerMode, WorkerSpec};
use crate::state::load_monitor::router_inflight_load::MockClock;
fn worker(id: &str) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}"),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}))
}
#[test]
fn a_live_binding_wins_over_a_later_bind_and_a_dead_one_is_replaced() {
let store = AffinityStore::new(Duration::from_secs(60));
let (a, b) = (worker("a"), worker("b"));
let fleet = [a.clone(), b.clone()];
assert_eq!(store.bind("k".into(), &a, &fleet).id.0, "a");
assert_eq!(store.bind("k".into(), &b, &fleet).id.0, "a");
assert_eq!(store.bind("k".into(), &b, &fleet[1..]).id.0, "b");
assert_eq!(store.bound("k", &fleet).unwrap().id.0, "b");
assert!(store.bound("k", &fleet[..1]).is_none());
}
#[test]
fn idle_bindings_are_swept_and_hits_refresh() {
let clock = Arc::new(MockClock::new(Instant::now()));
let store = AffinityStore::with_clock(Duration::from_secs(10), clock.clone());
let fleet = [worker("a")];
store.bind("hot".into(), &fleet[0], &fleet);
store.bind("cold".into(), &fleet[0], &fleet);
clock.advance(Duration::from_secs(8));
store.bound("hot", &fleet);
clock.advance(Duration::from_secs(8));
assert_eq!(store.sweep_expired(), 1);
assert!(store.contains("hot") && !store.contains("cold"));
}
#[test]
fn concurrent_bindings_do_not_count_as_evictions() {
use std::sync::Barrier;
let clock = Arc::new(MockClock::new(Instant::now()));
let store = AffinityStore::with_clock(Duration::from_secs(60), clock);
let engine = worker("a");
let start = Arc::new(Barrier::new(5));
std::thread::scope(|scope| {
for writer in 0..4 {
let store = Arc::clone(&store);
let engine = Arc::clone(&engine);
let start = Arc::clone(&start);
scope.spawn(move || {
start.wait();
for key in 0..5000 {
store.bind(
format!("{writer}-{key}"),
&engine,
std::slice::from_ref(&engine),
);
}
});
}
start.wait();
for _ in 0..1000 {
// The clock never advances: every binding must survive,
// even when requests insert new keys during a sweep.
assert_eq!(store.sweep_expired(), 0);
}
});
assert_eq!(store.len(), 20_000);
}
}
@@ -8,7 +8,7 @@
//! `compute_block_hashes` must hash with the **same** block size the //! `compute_block_hashes` must hash with the **same** block size the
//! worker uses to publish KV-cache events; otherwise every cache-aware //! worker uses to publish KV-cache events; otherwise every cache-aware
//! lookup misses silently. The worker advertises its `page_size` via //! lookup misses silently. The worker advertises its `page_size` via
//! `/server_info` (parsed into [`crate::policies::kv_events::EventConfig::block_size`]). //! `/server_info` (parsed into [`crate::state::kv_events::EventConfig::block_size`]).
//! Dynamo's design treats `kv_cache_block_size` as a property of the //! Dynamo's design treats `kv_cache_block_size` as a property of the
//! `ModelDeploymentCard` populated by the worker registrar (see //! `ModelDeploymentCard` populated by the worker registrar (see
//! `~/dynamo/components/src/dynamo/sglang/register.py`); a mismatch //! `~/dynamo/components/src/dynamo/sglang/register.py`); a mismatch
@@ -7,7 +7,7 @@
//! always operate together in production: //! always operate together in production:
//! //!
//! - [`HashTree`] — the cache-aware routing index keyed by SGLang block hash. //! - [`HashTree`] — the cache-aware routing index keyed by SGLang block hash.
//! - [`EngineLoadTable`] — engine-reported per-worker load. //! - [`EngineReportedLoadTable`] — engine-reported per-worker load.
//! - Two [`KvEventSubscriberRegistry`]s — one per `(worker_url, dp_rank)` on //! - Two [`KvEventSubscriberRegistry`]s — one per `(worker_url, dp_rank)` on
//! the cache topic, one on the load topic. //! the cache topic, one on the load topic.
//! - A pump task that drains [`WorkerEvent`]s and applies KV batches to the //! - A pump task that drains [`WorkerEvent`]s and applies KV batches to the
@@ -41,7 +41,7 @@ use super::subscriber::{KvEventSubscriberRegistry, SubKind, WorkerEvent};
use super::tally::{EventKind, EventTally}; use super::tally::{EventKind, EventTally};
use super::tree::{HashTree, KvWorkerId, Tiers}; use super::tree::{HashTree, KvWorkerId, Tiers};
use super::wire::KvCacheEvent; use super::wire::KvCacheEvent;
use crate::policies::engine_load::EngineLoadTable; use crate::state::load_monitor::engine_reported_load::EngineReportedLoadTable;
/// Channel buffer between the subscriber registry and the pump task. /// Channel buffer between the subscriber registry and the pump task.
/// ///
@@ -96,13 +96,13 @@ pub struct KvEventIndex {
maintain_tree: bool, maintain_tree: bool,
subscribers: Arc<KvEventSubscriberRegistry>, subscribers: Arc<KvEventSubscriberRegistry>,
/// Second registry subscribing to the load topic (one per worker rank), /// Second registry subscribing to the load topic (one per worker rank),
/// feeding `LoadStat` snapshots into `engine_load`. Shares the pump /// feeding `LoadStat` snapshots into `engine_reported_load`. Shares the pump
/// channel with `subscribers`; keyed independently so KV and load /// channel with `subscribers`; keyed independently so KV and load
/// subscribers for the same worker don't collide. /// subscribers for the same worker don't collide.
load_subscribers: Arc<KvEventSubscriberRegistry>, load_subscribers: Arc<KvEventSubscriberRegistry>,
/// Engine-reported per-worker load, written by the pump from /// Engine-reported per-worker load, written by the pump from
/// `WorkerEvent::Load` and captured at request ingress. /// `WorkerEvent::Load` and captured at request ingress.
engine_load: Arc<EngineLoadTable>, engine_reported_load: Arc<EngineReportedLoadTable>,
pump: Mutex<Option<JoinHandle<()>>>, pump: Mutex<Option<JoinHandle<()>>>,
pump_cancel: CancellationToken, pump_cancel: CancellationToken,
workers: Mutex<HashMap<String, WorkerEntry>>, workers: Mutex<HashMap<String, WorkerEntry>>,
@@ -177,14 +177,14 @@ impl KvEventIndex {
let (tx, rx) = mpsc::channel::<WorkerEvent>(EVENT_CHANNEL_BUFFER); let (tx, rx) = mpsc::channel::<WorkerEvent>(EVENT_CHANNEL_BUFFER);
let subscribers = Arc::new(KvEventSubscriberRegistry::new(tx.clone())); let subscribers = Arc::new(KvEventSubscriberRegistry::new(tx.clone()));
let load_subscribers = Arc::new(KvEventSubscriberRegistry::with_kind(tx, SubKind::Load)); let load_subscribers = Arc::new(KvEventSubscriberRegistry::with_kind(tx, SubKind::Load));
let engine_load = EngineLoadTable::new(); let engine_reported_load = EngineReportedLoadTable::new();
let cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>> = Arc::new(Mutex::new(HashMap::new())); let cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>> = Arc::new(Mutex::new(HashMap::new()));
let live_workers: Arc<Mutex<HashSet<KvWorkerId>>> = Arc::new(Mutex::new(HashSet::new())); let live_workers: Arc<Mutex<HashSet<KvWorkerId>>> = Arc::new(Mutex::new(HashSet::new()));
let pump_cancel = CancellationToken::new(); let pump_cancel = CancellationToken::new();
let tally = Arc::new(EventTally::new()); let tally = Arc::new(EventTally::new());
let pump = tokio::spawn(pump_loop( let pump = tokio::spawn(pump_loop(
tree.clone(), tree.clone(),
engine_load.clone(), engine_reported_load.clone(),
cursors.clone(), cursors.clone(),
live_workers.clone(), live_workers.clone(),
Arc::clone(&tally), Arc::clone(&tally),
@@ -196,7 +196,7 @@ impl KvEventIndex {
maintain_tree, maintain_tree,
subscribers, subscribers,
load_subscribers, load_subscribers,
engine_load, engine_reported_load,
pump: Mutex::new(Some(pump)), pump: Mutex::new(Some(pump)),
pump_cancel, pump_cancel,
workers: Mutex::new(HashMap::new()), workers: Mutex::new(HashMap::new()),
@@ -239,8 +239,8 @@ impl KvEventIndex {
/// Shared accessor for the engine-load table. Load values are written solely by the pump /// Shared accessor for the engine-load table. Load values are written solely by the pump
/// (from `LoadStat` events); `add_worker` / `remove_worker` here manage /// (from `LoadStat` events); `add_worker` / `remove_worker` here manage
/// the expected set and per-worker eviction. /// the expected set and per-worker eviction.
pub fn engine_load(&self) -> Arc<EngineLoadTable> { pub fn engine_reported_load(&self) -> Arc<EngineReportedLoadTable> {
Arc::clone(&self.engine_load) Arc::clone(&self.engine_reported_load)
} }
/// Register a worker. If `preresolved` is `Some`, the caller has /// Register a worker. If `preresolved` is `Some`, the caller has
@@ -369,11 +369,12 @@ impl KvEventIndex {
if self.maintain_tree && !kv_dp_ranks.is_empty() { if self.maintain_tree && !kv_dp_ranks.is_empty() {
self.subscribers.add_worker(worker_url, &cfg).await; self.subscribers.add_worker(worker_url, &cfg).await;
} }
// Mark only the ranks that have an actual SUB socket. `EngineLoadTable` // Mark only the ranks that have an actual SUB socket. `EngineReportedLoadTable`
// then rejects missing or stale advertised ranks as a whole worker. // then rejects missing or stale advertised ranks as a whole worker.
if !load_dp_ranks.is_empty() { if !load_dp_ranks.is_empty() {
for rank in &load_dp_ranks { for rank in &load_dp_ranks {
self.engine_load.mark_expected_rank(worker_url, *rank); self.engine_reported_load
.mark_expected_rank(worker_url, *rank);
} }
self.load_subscribers.add_worker(worker_url, &cfg).await; self.load_subscribers.add_worker(worker_url, &cfg).await;
} }
@@ -412,7 +413,7 @@ impl KvEventIndex {
// 3. Drop each rank's tree state and cursor, and the worker's engine // 3. Drop each rank's tree state and cursor, and the worker's engine
// load. Any event already in the mpsc buffer at this point will be // load. Any event already in the mpsc buffer at this point will be
// filtered by the live-set check inside the pump. // filtered by the live-set check inside the pump.
self.engine_load.forget_worker(worker_url); self.engine_reported_load.forget_worker(worker_url);
let mut cursors = self.cursors.lock(); let mut cursors = self.cursors.lock();
for id in &ids { for id in &ids {
self.tree.clear_worker(id); self.tree.clear_worker(id);
@@ -457,7 +458,7 @@ impl KvEventIndex {
/// restarting from seq=1 (after sending END_SEQ) is not filtered. /// restarting from seq=1 (after sending END_SEQ) is not filtered.
async fn pump_loop( async fn pump_loop(
tree: Arc<HashTree>, tree: Arc<HashTree>,
engine_load: Arc<EngineLoadTable>, engine_reported_load: Arc<EngineReportedLoadTable>,
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>, cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
live_workers: Arc<Mutex<HashSet<KvWorkerId>>>, live_workers: Arc<Mutex<HashSet<KvWorkerId>>>,
tally: Arc<EventTally>, tally: Arc<EventTally>,
@@ -497,7 +498,7 @@ async fn pump_loop(
WorkerEvent::Load { worker, load } => { WorkerEvent::Load { worker, load } => {
// Gauge: last value wins, no sequence/dedup. The live-worker // Gauge: last value wins, no sequence/dedup. The live-worker
// filter above already dropped load from detached workers. // filter above already dropped load from detached workers.
engine_load.set(&worker.url, worker.dp_rank, load, Instant::now()); engine_reported_load.set(&worker.url, worker.dp_rank, load, Instant::now());
} }
WorkerEvent::PublisherReset { worker } => { WorkerEvent::PublisherReset { worker } => {
if cursors.lock().remove(&worker).is_some() { if cursors.lock().remove(&worker).is_some() {
@@ -585,8 +586,8 @@ async fn pump_loop(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::policies::engine_load::LoadStat; use crate::state::kv_events::wire::{BlockRemoved, BlockStored, KvEventBatch};
use crate::policies::kv_events::wire::{BlockRemoved, BlockStored, KvEventBatch}; use crate::state::load_monitor::engine_reported_load::LoadStat;
fn worker_id(url: &str, rank: u32) -> KvWorkerId { fn worker_id(url: &str, rank: u32) -> KvWorkerId {
KvWorkerId { KvWorkerId {
@@ -607,7 +608,7 @@ mod tests {
/// can destructure just the bits they need. /// can destructure just the bits they need.
struct PumpHarness { struct PumpHarness {
tree: Arc<HashTree>, tree: Arc<HashTree>,
engine_load: Arc<EngineLoadTable>, engine_reported_load: Arc<EngineReportedLoadTable>,
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>, cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
tally: Arc<EventTally>, tally: Arc<EventTally>,
#[allow(dead_code)] #[allow(dead_code)]
@@ -622,7 +623,7 @@ mod tests {
/// the given workers pre-marked live. /// the given workers pre-marked live.
fn spawn_pump(live: &[KvWorkerId]) -> PumpHarness { fn spawn_pump(live: &[KvWorkerId]) -> PumpHarness {
let tree = Arc::new(HashTree::new()); let tree = Arc::new(HashTree::new());
let engine_load = EngineLoadTable::new(); let engine_reported_load = EngineReportedLoadTable::new();
let cursors = Arc::new(Mutex::new(HashMap::new())); let cursors = Arc::new(Mutex::new(HashMap::new()));
let live_set: Arc<Mutex<HashSet<KvWorkerId>>> = let live_set: Arc<Mutex<HashSet<KvWorkerId>>> =
Arc::new(Mutex::new(live.iter().cloned().collect())); Arc::new(Mutex::new(live.iter().cloned().collect()));
@@ -631,7 +632,7 @@ mod tests {
let (tx, rx) = mpsc::channel(4); let (tx, rx) = mpsc::channel(4);
let pump = tokio::spawn(pump_loop( let pump = tokio::spawn(pump_loop(
tree.clone(), tree.clone(),
engine_load.clone(), engine_reported_load.clone(),
cursors.clone(), cursors.clone(),
live_set.clone(), live_set.clone(),
Arc::clone(&tally), Arc::clone(&tally),
@@ -640,7 +641,7 @@ mod tests {
)); ));
PumpHarness { PumpHarness {
tree, tree,
engine_load, engine_reported_load,
cursors, cursors,
tally, tally,
live_set, live_set,
@@ -833,7 +834,7 @@ mod tests {
async fn pump_applies_load_to_engine_load_table() { async fn pump_applies_load_to_engine_load_table() {
let id = worker_id("http://w1", 0); let id = worker_id("http://w1", 0);
let h = spawn_pump(std::slice::from_ref(&id)); let h = spawn_pump(std::slice::from_ref(&id));
let (tree, engine_load, tx, pump) = (h.tree, h.engine_load, h.tx, h.pump); let (tree, engine_reported_load, tx, pump) = (h.tree, h.engine_reported_load, h.tx, h.pump);
tx.send(WorkerEvent::Load { tx.send(WorkerEvent::Load {
worker: id.clone(), worker: id.clone(),
@@ -850,7 +851,7 @@ mod tests {
drop(tx); drop(tx);
pump.await.unwrap(); pump.await.unwrap();
let snapshot = engine_load.capture_snapshot(Instant::now()); let snapshot = engine_reported_load.capture_snapshot(Instant::now());
let load = snapshot.fresh_load_for_url("http://w1").unwrap(); let load = snapshot.fresh_load_for_url("http://w1").unwrap();
assert_eq!(load.num_running_reqs + load.num_waiting_reqs, 12); assert_eq!(load.num_running_reqs + load.num_waiting_reqs, 12);
// Load events must not pollute the cache tree. // Load events must not pollute the cache tree.
@@ -1105,7 +1106,7 @@ mod tests {
assert_eq!(oracle.get(), Some(64)); assert_eq!(oracle.get(), Some(64));
assert!(oracle.is_bigram()); assert!(oracle.is_bigram());
assert_eq!(index.known_worker_count(), 1); assert_eq!(index.known_worker_count(), 1);
assert_eq!(index.engine_load().expected_count(), 1); assert_eq!(index.engine_reported_load().expected_count(), 1);
index.shutdown().await; index.shutdown().await;
} }
@@ -1128,10 +1129,10 @@ mod tests {
is_bigram: false, is_bigram: false,
}; };
index.add_worker(url, Some(cfg)).await; index.add_worker(url, Some(cfg)).await;
assert_eq!(index.engine_load().expected_count(), 1); assert_eq!(index.engine_reported_load().expected_count(), 1);
let now = Instant::now(); let now = Instant::now();
index.engine_load().set( index.engine_reported_load().set(
url, url,
0, 0,
LoadStat { LoadStat {
@@ -1144,7 +1145,7 @@ mod tests {
now, now,
); );
assert!(index assert!(index
.engine_load() .engine_reported_load()
.capture_snapshot(now) .capture_snapshot(now)
.fresh_load_for_url(url) .fresh_load_for_url(url)
.is_some()); .is_some());
@@ -1152,13 +1153,13 @@ mod tests {
index.remove_worker(url).await; index.remove_worker(url).await;
assert!( assert!(
index index
.engine_load() .engine_reported_load()
.capture_snapshot(Instant::now()) .capture_snapshot(Instant::now())
.fresh_load_for_url(url) .fresh_load_for_url(url)
.is_none(), .is_none(),
"remove_worker must clear engine load" "remove_worker must clear engine load"
); );
assert_eq!(index.engine_load().expected_count(), 0); assert_eq!(index.engine_reported_load().expected_count(), 0);
index.shutdown().await; index.shutdown().await;
} }
@@ -7,7 +7,7 @@
//! consumed by [`super::index::KvEventIndex`]. Each `(worker_url, dp_rank)` //! consumed by [`super::index::KvEventIndex`]. Each `(worker_url, dp_rank)`
//! pair gets its own SUB socket on its own tokio task, decodes msgpack frames //! pair gets its own SUB socket on its own tokio task, decodes msgpack frames
//! by [`SubKind`] (KV batches via [`super::wire`], load via //! by [`SubKind`] (KV batches via [`super::wire`], load via
//! [`crate::policies::engine_load`]), and forwards [`WorkerEvent`]s to a //! [`crate::state::load_monitor::engine_reported_load`]), and forwards [`WorkerEvent`]s to a
//! shared mpsc channel. //! shared mpsc channel.
//! //!
//! # Wire format (3-frame multipart) //! # Wire format (3-frame multipart)
@@ -72,7 +72,7 @@ use zeromq::{Socket, SocketRecv, SubSocket, ZmqMessage};
use super::discovery::EventConfig; use super::discovery::EventConfig;
use super::tree::KvWorkerId; use super::tree::KvWorkerId;
use super::wire::{decode_event_batch, KvEventBatch}; use super::wire::{decode_event_batch, KvEventBatch};
use crate::policies::engine_load::{decode_load_stat, LoadStat}; use crate::state::load_monitor::engine_reported_load::{decode_load_stat, LoadStat};
/// Maximum number of consecutive `recv()` errors before the subscriber /// Maximum number of consecutive `recv()` errors before the subscriber
/// gives up and exits its task. ZMQ's internal reconnect handles transient /// gives up and exits its task. ZMQ's internal reconnect handles transient
@@ -665,7 +665,7 @@ mod tests {
use tokio::time::timeout; use tokio::time::timeout;
use zeromq::{Endpoint, PubSocket, Socket, SocketSend, ZmqMessage}; use zeromq::{Endpoint, PubSocket, Socket, SocketSend, ZmqMessage};
use crate::policies::kv_events::wire::KvCacheEvent; use crate::state::kv_events::wire::KvCacheEvent;
mod helpers { mod helpers {
use super::*; use super::*;
@@ -10,7 +10,7 @@
//! aggregate and falls back to Router-local load when it is unavailable. //! aggregate and falls back to Router-local load when it is unavailable.
//! //!
//! Load is a *gauge*, not a delta: last value wins, no sequence/replay //! Load is a *gauge*, not a delta: last value wins, no sequence/replay
//! semantics. Entries older than [`EngineLoadTable::freshness`] are ignored. //! semantics. Entries older than [`EngineReportedLoadTable::freshness`] are ignored.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt; use std::fmt;
@@ -58,12 +58,12 @@ pub struct LoadStat {
pub native_cache: Option<NativeCacheRankLoad>, pub native_cache: Option<NativeCacheRankLoad>,
} }
/// Engine load for one worker captured at a fixed point in time. /// Engine-reported request and KV counters for one worker, summed across DP ranks.
/// ///
/// The four #34608 fields are summed across DP ranks. `captured_at` retains /// The four #34608 fields are summed across DP ranks. `captured_at` retains
/// the oldest rank timestamp so later local dispatches can be added. /// the oldest rank timestamp so later local dispatches can be added.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct EngineWorkerLoad { pub struct EngineReportedWorkerLoad {
pub num_running_reqs: u64, pub num_running_reqs: u64,
pub num_waiting_reqs: u64, pub num_waiting_reqs: u64,
pub num_tokens: u64, pub num_tokens: u64,
@@ -71,12 +71,14 @@ pub struct EngineWorkerLoad {
pub captured_at: Instant, pub captured_at: Instant,
} }
/// Complete ZMQ monitor aggregate used by native Cache-Aware. /// Engine-reported per-worker scheduling load: capacity, queue pressure, and prefill estimates.
///
/// Shared by cache-aware, power-of-two, session-aware, and decode selection and admission.
/// ///
/// Prefill throughput and queue time require two monotonic samples from every /// Prefill throughput and queue time require two monotonic samples from every
/// DP rank. Initial samples and counter resets leave both values unavailable. /// DP rank. Initial samples and counter resets leave both values unavailable.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct NativeCacheWorkerLoad { pub struct EngineReportedSchedulingLoad {
pub num_running_reqs: u64, pub num_running_reqs: u64,
pub num_waiting_reqs: u64, pub num_waiting_reqs: u64,
pub num_waiting_uncached_tokens: u64, pub num_waiting_uncached_tokens: u64,
@@ -89,19 +91,19 @@ pub struct NativeCacheWorkerLoad {
pub captured_at: Instant, pub captured_at: Instant,
} }
/// Immutable engine load view captured once at request ingress. /// Immutable fleet-wide view of engine-reported load, captured once at request ingress.
/// ///
/// Keys are worker URLs used for dispatch. Missing, stale, or rank-incomplete /// Keys are worker URLs used for dispatch. Missing, stale, or rank-incomplete
/// workers are omitted and must use Router-local active load. /// workers are omitted and must use Router-local active load.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct EngineLoadSnapshot { pub struct EngineReportedLoadSnapshot {
pub version: u64, pub version: u64,
workers: HashMap<String, EngineWorkerLoad>, workers: HashMap<String, EngineReportedWorkerLoad>,
native_cache_workers: HashMap<String, NativeCacheWorkerLoad>, native_cache_workers: HashMap<String, EngineReportedSchedulingLoad>,
} }
impl EngineLoadSnapshot { impl EngineReportedLoadSnapshot {
pub fn fresh_load_for_url(&self, worker_url: &str) -> Option<&EngineWorkerLoad> { pub fn fresh_load_for_url(&self, worker_url: &str) -> Option<&EngineReportedWorkerLoad> {
self.workers.get(worker_url) self.workers.get(worker_url)
} }
@@ -136,13 +138,13 @@ impl EngineLoadSnapshot {
pub fn fresh_native_cache_load_for_url( pub fn fresh_native_cache_load_for_url(
&self, &self,
worker_url: &str, worker_url: &str,
) -> Option<&NativeCacheWorkerLoad> { ) -> Option<&EngineReportedSchedulingLoad> {
self.native_cache_workers.get(worker_url) self.native_cache_workers.get(worker_url)
} }
/// Builds a view from worker data that already passed freshness and rank checks. /// Builds a view from worker data that already passed freshness and rank checks.
/// Production requests should use [`EngineLoadTable::capture_snapshot`]. /// Production requests should use [`EngineReportedLoadTable::capture_snapshot`].
pub fn from_workers(version: u64, workers: HashMap<String, EngineWorkerLoad>) -> Self { pub fn from_workers(version: u64, workers: HashMap<String, EngineReportedWorkerLoad>) -> Self {
Self { Self {
version, version,
workers, workers,
@@ -151,17 +153,17 @@ impl EngineLoadSnapshot {
} }
/// Builds a test snapshot from complete native monitor data. /// Builds a test snapshot from complete native monitor data.
/// Production requests must use [`EngineLoadTable::capture_snapshot`]. /// Production requests must use [`EngineReportedLoadTable::capture_snapshot`].
pub fn from_native_cache_workers( pub fn from_native_cache_workers(
version: u64, version: u64,
workers: HashMap<String, NativeCacheWorkerLoad>, workers: HashMap<String, EngineReportedSchedulingLoad>,
) -> Self { ) -> Self {
let basic = workers let basic = workers
.iter() .iter()
.map(|(url, load)| { .map(|(url, load)| {
( (
url.clone(), url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: load.num_running_reqs, num_running_reqs: load.num_running_reqs,
num_waiting_reqs: load.num_waiting_reqs, num_waiting_reqs: load.num_waiting_reqs,
num_tokens: load.num_used_tokens, num_tokens: load.num_used_tokens,
@@ -292,7 +294,7 @@ type NativeWorkerObservations = HashMap<u32, NativeRankObservation>;
/// Per-`(worker_url, dp_rank)` engine-reported load, written by the load /// Per-`(worker_url, dp_rank)` engine-reported load, written by the load
/// subscriber pump and captured once at request ingress. /// subscriber pump and captured once at request ingress.
#[derive(Debug)] #[derive(Debug)]
pub struct EngineLoadTable { pub struct EngineReportedLoadTable {
by_rank: DashMap<(String, u32), LoadEntry>, by_rank: DashMap<(String, u32), LoadEntry>,
/// Per-rank publishers the worker advertised. A worker is usable only /// Per-rank publishers the worker advertised. A worker is usable only
/// when every advertised rank has a fresh value; accepting a partial /// when every advertised rank has a fresh value; accepting a partial
@@ -302,7 +304,7 @@ pub struct EngineLoadTable {
version: AtomicU64, version: AtomicU64,
} }
impl EngineLoadTable { impl EngineReportedLoadTable {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
by_rank: DashMap::new(), by_rank: DashMap::new(),
@@ -364,7 +366,7 @@ impl EngineLoadTable {
/// look misleadingly idle and draw *more* traffic.) Callers that never /// look misleadingly idle and draw *more* traffic.) Callers that never
/// registered expected ranks retain the all-known-ranks rule. The oldest /// registered expected ranks retain the all-known-ranks rule. The oldest
/// timestamp represents the freshness of the complete aggregate. /// timestamp represents the freshness of the complete aggregate.
fn fresh_worker_loads(&self, now: Instant) -> HashMap<String, EngineWorkerLoad> { fn fresh_worker_loads(&self, now: Instant) -> HashMap<String, EngineReportedWorkerLoad> {
// url -> rank -> (reported load, fresh, timestamp). // url -> rank -> (reported load, fresh, timestamp).
let mut observed: HashMap<String, HashMap<u32, (LoadStat, bool, Instant)>> = HashMap::new(); let mut observed: HashMap<String, HashMap<u32, (LoadStat, bool, Instant)>> = HashMap::new();
for entry in self.by_rank.iter() { for entry in self.by_rank.iter() {
@@ -412,7 +414,7 @@ impl EngineLoadTable {
oldest_at.map(|captured_at| { oldest_at.map(|captured_at| {
( (
url, url,
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs, num_running_reqs,
num_waiting_reqs, num_waiting_reqs,
num_tokens, num_tokens,
@@ -432,7 +434,7 @@ impl EngineLoadTable {
fn fresh_native_cache_worker_loads( fn fresh_native_cache_worker_loads(
&self, &self,
now: Instant, now: Instant,
) -> HashMap<String, NativeCacheWorkerLoad> { ) -> HashMap<String, EngineReportedSchedulingLoad> {
let mut observed: HashMap<String, NativeWorkerObservations> = HashMap::new(); let mut observed: HashMap<String, NativeWorkerObservations> = HashMap::new();
for entry in self.by_rank.iter() { for entry in self.by_rank.iter() {
let at = entry.value().at; let at = entry.value().at;
@@ -521,7 +523,7 @@ impl EngineLoadTable {
oldest_at.map(|captured_at| { oldest_at.map(|captured_at| {
( (
url, url,
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs, num_running_reqs,
num_waiting_reqs, num_waiting_reqs,
num_waiting_uncached_tokens, num_waiting_uncached_tokens,
@@ -540,8 +542,8 @@ impl EngineLoadTable {
} }
/// Captures one immutable view for all routing decisions in a request. /// Captures one immutable view for all routing decisions in a request.
pub fn capture_snapshot(&self, now: Instant) -> EngineLoadSnapshot { pub fn capture_snapshot(&self, now: Instant) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot { EngineReportedLoadSnapshot {
version: self.version.load(Ordering::Acquire), version: self.version.load(Ordering::Acquire),
workers: self.fresh_worker_loads(now), workers: self.fresh_worker_loads(now),
native_cache_workers: self.fresh_native_cache_worker_loads(now), native_cache_workers: self.fresh_native_cache_worker_loads(now),
@@ -635,7 +637,7 @@ mod tests {
#[test] #[test]
fn sums_queue_depth_across_ranks() { fn sums_queue_depth_across_ranks() {
let t = EngineLoadTable::new(); let t = EngineReportedLoadTable::new();
let now = Instant::now(); let now = Instant::now();
t.set("http://w:30000", 0, load(5, 1), now); t.set("http://w:30000", 0, load(5, 1), now);
t.set("http://w:30000", 1, load(3, 2), now); t.set("http://w:30000", 1, load(3, 2), now);
@@ -647,7 +649,7 @@ mod tests {
#[test] #[test]
fn stale_entries_are_dropped_from_snapshot() { fn stale_entries_are_dropped_from_snapshot() {
let t = EngineLoadTable::with_freshness(Duration::from_millis(10)); let t = EngineReportedLoadTable::with_freshness(Duration::from_millis(10));
let old = Instant::now(); let old = Instant::now();
t.set("http://w:30000", 0, load(9, 9), old); t.set("http://w:30000", 0, load(9, 9), old);
// A read far in the future sees the entry as stale -> worker absent. // A read far in the future sees the entry as stale -> worker absent.
@@ -660,7 +662,7 @@ mod tests {
#[test] #[test]
fn forget_worker_clears_all_ranks() { fn forget_worker_clears_all_ranks() {
let t = EngineLoadTable::new(); let t = EngineReportedLoadTable::new();
let now = Instant::now(); let now = Instant::now();
t.set("http://w:30000", 0, load(1, 0), now); t.set("http://w:30000", 0, load(1, 0), now);
t.set("http://w:30000", 1, load(1, 0), now); t.set("http://w:30000", 1, load(1, 0), now);
@@ -677,7 +679,7 @@ mod tests {
/// router-side counter instead of looking misleadingly idle. /// router-side counter instead of looking misleadingly idle.
#[test] #[test]
fn partial_freshness_excludes_worker() { fn partial_freshness_excludes_worker() {
let t = EngineLoadTable::with_freshness(Duration::from_secs(5)); let t = EngineReportedLoadTable::with_freshness(Duration::from_secs(5));
let now = Instant::now(); let now = Instant::now();
let stale = now - Duration::from_secs(3600); let stale = now - Duration::from_secs(3600);
t.set("http://w:30000", 0, load(5, 1), now); // fresh t.set("http://w:30000", 0, load(5, 1), now); // fresh
@@ -692,7 +694,7 @@ mod tests {
#[test] #[test]
fn missing_expected_rank_excludes_worker() { fn missing_expected_rank_excludes_worker() {
let t = EngineLoadTable::new(); let t = EngineReportedLoadTable::new();
let now = Instant::now(); let now = Instant::now();
t.mark_expected_rank("http://w:30000", 0); t.mark_expected_rank("http://w:30000", 0);
t.mark_expected_rank("http://w:30000", 1); t.mark_expected_rank("http://w:30000", 1);
@@ -712,7 +714,7 @@ mod tests {
#[test] #[test]
fn capture_snapshot_uses_the_earliest_rank_timestamp() { fn capture_snapshot_uses_the_earliest_rank_timestamp() {
let t = EngineLoadTable::new(); let t = EngineReportedLoadTable::new();
let earlier = Instant::now() - Duration::from_secs(2); let earlier = Instant::now() - Duration::from_secs(2);
let later = earlier + Duration::from_secs(1); let later = earlier + Duration::from_secs(1);
t.set("http://w:30000", 0, load(5, 1), later); t.set("http://w:30000", 0, load(5, 1), later);
@@ -726,7 +728,7 @@ mod tests {
#[test] #[test]
fn expected_count_tracks_marked_workers_and_forget() { fn expected_count_tracks_marked_workers_and_forget() {
let t = EngineLoadTable::new(); let t = EngineReportedLoadTable::new();
assert_eq!(t.expected_count(), 0); assert_eq!(t.expected_count(), 0);
t.mark_expected_rank("http://w:30000", 0); t.mark_expected_rank("http://w:30000", 0);
t.mark_expected_rank("http://w:30000", 1); // same worker t.mark_expected_rank("http://w:30000", 1); // same worker
@@ -738,7 +740,7 @@ mod tests {
#[test] #[test]
fn complete_v3_semantic_samples_derive_prefill_queue_time() { fn complete_v3_semantic_samples_derive_prefill_queue_time() {
let t = EngineLoadTable::new(); let t = EngineReportedLoadTable::new();
let first = Instant::now(); let first = Instant::now();
let second = first + Duration::from_secs(2); let second = first + Duration::from_secs(2);
let mut old = load(2, 3); let mut old = load(2, 3);
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Load signals: engine-reported load and router-local in-flight accounting.
pub mod engine_reported_load;
pub mod router_inflight_load;
@@ -1,7 +1,7 @@
// 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
//! Per-worker active-load tracking with RAII guards and a stale-request //! Router-local per-worker in-flight load tracking with RAII guards and a stale-request
//! janitor. //! janitor.
//! //!
//! The per-worker `Worker::active_requests` counter tracks in-flight HTTP //! The per-worker `Worker::active_requests` counter tracks in-flight HTTP
@@ -18,7 +18,7 @@
//! 2. **Two-axis tracking** so PD-disaggregation can score prefill (token //! 2. **Two-axis tracking** so PD-disaggregation can score prefill (token
//! count) separately from decode (block count). The two counters share //! count) separately from decode (block count). The two counters share
//! the same registry shape; we expose them as a single //! the same registry shape; we expose them as a single
//! [`ActiveLoadGuard`] holding both so the proxy's hot path mints one //! [`RouterInflightLoadGuard`] holding both so the proxy's hot path mints one
//! guard per request rather than two. //! guard per request rather than two.
//! //!
//! # Drop semantics //! # Drop semantics
@@ -34,7 +34,7 @@
//! //!
//! # Clock injection //! # Clock injection
//! //!
//! [`ActiveLoadRegistry::new`] is generic over the clock so tests can drive //! [`RouterInflightLoadRegistry::new`] is generic over the clock so tests can drive
//! the janitor deterministically. Production wires a `SystemTimeClock`; //! the janitor deterministically. Production wires a `SystemTimeClock`;
//! tests use a `MockClock`. The `Instant`-based timestamp on registration //! tests use a `MockClock`. The `Instant`-based timestamp on registration
//! is sufficient for the timeout comparison (monotonic), so the clock //! is sufficient for the timeout comparison (monotonic), so the clock
@@ -42,7 +42,7 @@
//! type whose `duration_since(other)` returns the wall-clock delta. //! type whose `duration_since(other)` returns the wall-clock delta.
use crate::discovery::WorkerId; use crate::discovery::WorkerId;
use crate::server::metrics::{ActiveLoadKind, MetricsRegistry}; use crate::server::metrics::{MetricsRegistry, RouterInflightLoadKind};
use dashmap::DashMap; use dashmap::DashMap;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
@@ -52,7 +52,7 @@ use tokio_util::sync::CancellationToken;
use uuid::Uuid; use uuid::Uuid;
/// Unique identifier for an in-flight request. Minted by /// Unique identifier for an in-flight request. Minted by
/// [`ActiveLoadRegistry::register`] and carried inside [`ActiveLoadGuard`] /// [`RouterInflightLoadRegistry::register`] and carried inside [`RouterInflightLoadGuard`]
/// so the janitor can address one request at a time. /// so the janitor can address one request at a time.
#[derive(Clone, Eq, Hash, PartialEq, Debug)] #[derive(Clone, Eq, Hash, PartialEq, Debug)]
pub struct RequestId(pub Uuid); pub struct RequestId(pub Uuid);
@@ -86,7 +86,7 @@ struct WorkerCounters {
/// ///
/// `cancel` is a [`CancellationToken`] the janitor fires when the entry /// `cancel` is a [`CancellationToken`] the janitor fires when the entry
/// is swept. The chat handler holds a clone (via /// is swept. The chat handler holds a clone (via
/// [`ActiveLoadGuard::cancel_token`]) and aborts its upstream fetch /// [`RouterInflightLoadGuard::cancel_token`]) and aborts its upstream fetch
/// with `ApiError::StaleRequestExpired` when the token resolves — /// with `ApiError::StaleRequestExpired` when the token resolves —
/// surfacing the stale-request expiry as a 504 to the client instead /// surfacing the stale-request expiry as a 504 to the client instead
/// of leaving the handler hung on a long-lived upstream. /// of leaving the handler hung on a long-lived upstream.
@@ -166,12 +166,12 @@ impl Clock for MockClock {
/// Registry of in-flight requests + per-worker active-load counters. /// Registry of in-flight requests + per-worker active-load counters.
/// ///
/// Constructed once per `AppContext`; the proxy holds an [`ActiveLoadGuard`] /// Constructed once per `AppContext`; the proxy holds an [`RouterInflightLoadGuard`]
/// per request so counters decrement on drop. A background task periodically calls /// per request so counters decrement on drop. A background task periodically calls
/// [`Self::sweep_stale`] to evict requests that outlived /// [`Self::sweep_stale`] to evict requests that outlived
/// `stale_request_timeout`. /// `stale_request_timeout`.
#[derive(Debug)] #[derive(Debug)]
pub struct ActiveLoadRegistry { pub struct RouterInflightLoadRegistry {
workers: DashMap<WorkerId, Arc<WorkerCounters>>, workers: DashMap<WorkerId, Arc<WorkerCounters>>,
requests: DashMap<RequestId, RequestEntry>, requests: DashMap<RequestId, RequestEntry>,
clock: Arc<dyn Clock>, clock: Arc<dyn Clock>,
@@ -185,8 +185,8 @@ pub struct ActiveLoadRegistry {
metrics: Mutex<Option<Arc<MetricsRegistry>>>, metrics: Mutex<Option<Arc<MetricsRegistry>>>,
} }
impl ActiveLoadRegistry { impl RouterInflightLoadRegistry {
/// Construct an [`ActiveLoadRegistry`] wrapped in an [`Arc`]. /// Construct an [`RouterInflightLoadRegistry`] wrapped in an [`Arc`].
/// ///
/// The registry is always shared (proxy + janitor + selector all hold /// The registry is always shared (proxy + janitor + selector all hold
/// the same instance), so the public constructor mints the `Arc` /// the same instance), so the public constructor mints the `Arc`
@@ -221,14 +221,14 @@ impl ActiveLoadRegistry {
let Some(metrics) = self.metrics.lock().clone() else { let Some(metrics) = self.metrics.lock().clone() else {
return; return;
}; };
metrics.set_active_load( metrics.set_router_inflight_load(
worker_url, worker_url,
ActiveLoadKind::PrefillTokens, RouterInflightLoadKind::PrefillTokens,
counters.prefill_load.load(Ordering::Relaxed) as i64, counters.prefill_load.load(Ordering::Relaxed) as i64,
); );
metrics.set_active_load( metrics.set_router_inflight_load(
worker_url, worker_url,
ActiveLoadKind::DecodeBlocks, RouterInflightLoadKind::DecodeBlocks,
counters.decode_load.load(Ordering::Relaxed) as i64, counters.decode_load.load(Ordering::Relaxed) as i64,
); );
} }
@@ -262,7 +262,7 @@ impl ActiveLoadRegistry {
worker_url: impl Into<String>, worker_url: impl Into<String>,
prefill_load: usize, prefill_load: usize,
decode_load: usize, decode_load: usize,
) -> ActiveLoadGuard { ) -> RouterInflightLoadGuard {
let worker_url = worker_url.into(); let worker_url = worker_url.into();
let request_id = RequestId::new_v4(); let request_id = RequestId::new_v4();
let counters = self let counters = self
@@ -291,7 +291,7 @@ impl ActiveLoadRegistry {
cancel: cancel.clone(), cancel: cancel.clone(),
}, },
); );
ActiveLoadGuard { RouterInflightLoadGuard {
registry: Some(Arc::clone(self)), registry: Some(Arc::clone(self)),
request_id: Some(request_id), request_id: Some(request_id),
worker, worker,
@@ -398,7 +398,7 @@ impl ActiveLoadRegistry {
} }
/// Spawn a background janitor task that periodically calls /// Spawn a background janitor task that periodically calls
/// [`ActiveLoadRegistry::sweep_stale`]. /// [`RouterInflightLoadRegistry::sweep_stale`].
/// ///
/// Returns a [`JanitorHandle`] that owns the join handle and a cancellation /// Returns a [`JanitorHandle`] that owns the join handle and a cancellation
/// token. Dropping the handle cancels the task; calling /// token. Dropping the handle cancels the task; calling
@@ -407,9 +407,12 @@ impl ActiveLoadRegistry {
/// `interval` is the wall-clock cadence of the sweep. A sensible default /// `interval` is the wall-clock cadence of the sweep. A sensible default
/// is half the configured `stale_request_timeout` so an expired entry is /// is half the configured `stale_request_timeout` so an expired entry is
/// reaped within 1.5× the timeout in the worst case. Pass a fresh /// reaped within 1.5× the timeout in the worst case. Pass a fresh
/// `Arc<ActiveLoadRegistry>` (cloned from the shared one held in /// `Arc<RouterInflightLoadRegistry>` (cloned from the shared one held in
/// `AppContext`). /// `AppContext`).
pub fn spawn_janitor(registry: Arc<ActiveLoadRegistry>, interval: Duration) -> JanitorHandle { pub fn spawn_janitor(
registry: Arc<RouterInflightLoadRegistry>,
interval: Duration,
) -> JanitorHandle {
spawn_sweeper(move || registry.sweep_stale(), interval, "active-load") spawn_sweeper(move || registry.sweep_stale(), interval, "active-load")
} }
@@ -481,16 +484,16 @@ impl Drop for JanitorHandle {
} }
} }
/// RAII guard returned by [`ActiveLoadRegistry::register`]. /// RAII guard returned by [`RouterInflightLoadRegistry::register`].
/// ///
/// `#[must_use]`: a statement-form `registry.register(...)` would drop the /// `#[must_use]`: a statement-form `registry.register(...)` would drop the
/// guard on the same line and decrement the counter before the request /// guard on the same line and decrement the counter before the request
/// actually executed, defeating the purpose. The compile-time warning /// actually executed, defeating the purpose. The compile-time warning
/// catches that misuse. /// catches that misuse.
#[must_use = "ActiveLoadGuard must be held for the request's lifetime; dropping it immediately decrements counters"] #[must_use = "RouterInflightLoadGuard must be held for the request's lifetime; dropping it immediately decrements counters"]
#[derive(Debug)] #[derive(Debug)]
pub struct ActiveLoadGuard { pub struct RouterInflightLoadGuard {
registry: Option<Arc<ActiveLoadRegistry>>, registry: Option<Arc<RouterInflightLoadRegistry>>,
/// `None` after the janitor expired this request — drop becomes a /// `None` after the janitor expired this request — drop becomes a
/// no-op in that case. The guard keeps only the `RequestId`; the /// no-op in that case. The guard keeps only the `RequestId`; the
/// per-axis amounts (and the captured `Arc<WorkerCounters>`) live /// per-axis amounts (and the captured `Arc<WorkerCounters>`) live
@@ -505,7 +508,7 @@ pub struct ActiveLoadGuard {
cancel: CancellationToken, cancel: CancellationToken,
} }
impl ActiveLoadGuard { impl RouterInflightLoadGuard {
/// Read-only accessor (mainly for tests + diagnostic logging). /// Read-only accessor (mainly for tests + diagnostic logging).
pub fn worker(&self) -> &WorkerId { pub fn worker(&self) -> &WorkerId {
&self.worker &self.worker
@@ -520,7 +523,7 @@ impl ActiveLoadGuard {
} }
} }
impl Drop for ActiveLoadGuard { impl Drop for RouterInflightLoadGuard {
fn drop(&mut self) { fn drop(&mut self) {
// If the janitor already expired this request (or `expire_now` was // If the janitor already expired this request (or `expire_now` was
// called explicitly), `request_id` is `None` and we skip — the // called explicitly), `request_id` is `None` and we skip — the
@@ -531,7 +534,7 @@ impl Drop for ActiveLoadGuard {
// `remove` returns `Some` exactly once; if the janitor races us // `remove` returns `Some` exactly once; if the janitor races us
// and wins, we skip the decrement here. Decrement the **same** // and wins, we skip the decrement here. Decrement the **same**
// counters Arc the register call incremented (see // counters Arc the register call incremented (see
// `ActiveLoadGuard::counters`) — pinning the decrement to a // `RouterInflightLoadGuard::counters`) — pinning the decrement to a
// specific WorkerCounters instance keeps the math correct // specific WorkerCounters instance keeps the math correct
// across `forget_worker` + re-register cycles. // across `forget_worker` + re-register cycles.
if let Some((_, entry)) = registry.requests.remove(&id) { if let Some((_, entry)) = registry.requests.remove(&id) {
@@ -553,9 +556,12 @@ mod tests {
use super::*; use super::*;
use std::sync::Arc; use std::sync::Arc;
fn registry_with_mock_clock(timeout: Duration) -> (Arc<ActiveLoadRegistry>, Arc<MockClock>) { fn registry_with_mock_clock(
timeout: Duration,
) -> (Arc<RouterInflightLoadRegistry>, Arc<MockClock>) {
let clock = Arc::new(MockClock::new(Instant::now())); let clock = Arc::new(MockClock::new(Instant::now()));
let registry = ActiveLoadRegistry::new(Arc::clone(&clock) as Arc<dyn Clock>, timeout); let registry =
RouterInflightLoadRegistry::new(Arc::clone(&clock) as Arc<dyn Clock>, timeout);
(registry, clock) (registry, clock)
} }
@@ -617,7 +623,7 @@ mod tests {
/// Gap closer #2: double-drop safety. /// Gap closer #2: double-drop safety.
/// ///
/// Rust's affine type system makes a literal double-drop of the same /// Rust's affine type system makes a literal double-drop of the same
/// `ActiveLoadGuard` value impossible — the compiler rejects /// `RouterInflightLoadGuard` value impossible — the compiler rejects
/// `drop(g); drop(g);`. The interesting property is that the /// `drop(g); drop(g);`. The interesting property is that the
/// registry's own bookkeeping never under-decrements, even if the /// registry's own bookkeeping never under-decrements, even if the
/// janitor and a guard's drop race. We assert that by simulating the /// janitor and a guard's drop race. We assert that by simulating the
@@ -688,7 +694,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn spawn_janitor_sweeps_stale_entries() { async fn spawn_janitor_sweeps_stale_entries() {
let clock: Arc<dyn Clock> = Arc::new(SystemTimeClock); let clock: Arc<dyn Clock> = Arc::new(SystemTimeClock);
let registry = ActiveLoadRegistry::new(clock, Duration::from_millis(30)); let registry = RouterInflightLoadRegistry::new(clock, Duration::from_millis(30));
let w = WorkerId("w0".into()); let w = WorkerId("w0".into());
let _g = registry.register(w.clone(), "test://50-2", 50, 2); let _g = registry.register(w.clone(), "test://50-2", 50, 2);
assert_eq!(registry.inflight_count(), 1); assert_eq!(registry.inflight_count(), 1);
@@ -708,7 +714,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn spawn_janitor_shutdown_is_clean() { async fn spawn_janitor_shutdown_is_clean() {
let clock: Arc<dyn Clock> = Arc::new(SystemTimeClock); let clock: Arc<dyn Clock> = Arc::new(SystemTimeClock);
let registry = ActiveLoadRegistry::new(clock, Duration::from_secs(60)); let registry = RouterInflightLoadRegistry::new(clock, Duration::from_secs(60));
let handle = spawn_janitor(Arc::clone(&registry), Duration::from_millis(100)); let handle = spawn_janitor(Arc::clone(&registry), Duration::from_millis(100));
// Verify shutdown completes within a generous bound. // Verify shutdown completes within a generous bound.
let r = tokio::time::timeout(Duration::from_secs(2), handle.shutdown()).await; let r = tokio::time::timeout(Duration::from_secs(2), handle.shutdown()).await;
@@ -840,7 +846,7 @@ mod tests {
/// When a [`MetricsRegistry`] is attached, the per-worker active-load /// When a [`MetricsRegistry`] is attached, the per-worker active-load
/// gauge mirrors the live counter on register / drop / sweep. /// gauge mirrors the live counter on register / drop / sweep.
/// Regression: prior code exposed [`MetricsRegistry::set_active_load`] /// Regression: prior code exposed [`MetricsRegistry::set_router_inflight_load`]
/// but nothing in the request hot path ever called it, leaving /// but nothing in the request hot path ever called it, leaving
/// `sgl_router_active_load` permanently at 0 in production. /// `sgl_router_active_load` permanently at 0 in production.
#[test] #[test]
+12
View File
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Shared mutable state that selection reads: the KV-event cache index, load
//! monitoring, and affinity assignments. Both the legacy policies and
//! `policies_reorg` read it here.
pub mod affinity_store;
pub mod kv_events;
pub mod load_monitor;
pub use affinity_store::AffinityStore;
+1 -1
View File
@@ -186,7 +186,7 @@ mod tests {
}, },
), ),
proxy: crate::config::ProxyConfig::default(), proxy: crate::config::ProxyConfig::default(),
active_load: crate::config::ActiveLoadConfig::default(), router_inflight_load: crate::config::InflightLoadConfig::default(),
} }
} }
@@ -31,7 +31,7 @@ use serde::Deserialize;
use tracing::warn; use tracing::warn;
use url::Url; use url::Url;
use crate::policies::kv_events::EventConfig; use crate::state::kv_events::EventConfig;
/// Default timeout for `/server_info`. Conservative for a small JSON /// Default timeout for `/server_info`. Conservative for a small JSON
/// payload served by SGLang's HTTP server. /// payload served by SGLang's HTTP server.
@@ -148,7 +148,7 @@ impl WorkerIntrospector {
// EAGLE-family speculative decoding ⇒ the worker hashes KV blocks over // EAGLE-family speculative decoding ⇒ the worker hashes KV blocks over
// token bigrams; the router must mirror that on the selection side. // token bigrams; the router must mirror that on the selection side.
let is_bigram = crate::policies::kv_events::classify_bigram( let is_bigram = crate::state::kv_events::classify_bigram(
parsed.speculative_algorithm.as_deref(), parsed.speculative_algorithm.as_deref(),
worker_url, worker_url,
); );
+20 -20
View File
@@ -4,8 +4,8 @@
use crate::config::Config; use crate::config::Config;
use crate::discovery::{DiscoveryEvent, ModelId, WorkerId, WorkerMode, WorkerSpec}; use crate::discovery::{DiscoveryEvent, ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::health::circuit_breaker::CircuitBreakerConfig; use crate::health::circuit_breaker::CircuitBreakerConfig;
use crate::policies::active_load::ActiveLoadRegistry; use crate::state::kv_events::KvEventIndex;
use crate::policies::kv_events::KvEventIndex; use crate::state::load_monitor::router_inflight_load::RouterInflightLoadRegistry;
use crate::workers::introspect::{DisaggregationRole, WorkerIntrospector}; use crate::workers::introspect::{DisaggregationRole, WorkerIntrospector};
use crate::workers::{WireProtocol, WorkerRegistry}; use crate::workers::{WireProtocol, WorkerRegistry};
use std::collections::HashMap; use std::collections::HashMap;
@@ -109,7 +109,7 @@ pub async fn run(rx: mpsc::Receiver<DiscoveryEvent>, registry: Arc<WorkerRegistr
/// manager does not need a handle to the proxy. /// manager does not need a handle to the proxy.
/// ///
/// When `kv_index` is `None`, KV-event and load-subscriber state is disabled; when /// When `kv_index` is `None`, KV-event and load-subscriber state is disabled; when
/// `active_load` is `None` the active-load bookkeeping is not pruned /// `router_inflight_load` is `None` the active-load bookkeeping is not pruned
/// on worker removal (leaks one `WorkerCounters` slot per departed /// on worker removal (leaks one `WorkerCounters` slot per departed
/// worker — fine for tests, but production passes `Some(...)`); when /// worker — fine for tests, but production passes `Some(...)`); when
/// `cfg` is `None` the default CB config is used for every worker /// `cfg` is `None` the default CB config is used for every worker
@@ -123,14 +123,14 @@ pub async fn run_with_config(
registry: Arc<WorkerRegistry>, registry: Arc<WorkerRegistry>,
cfg: Option<Arc<Config>>, cfg: Option<Arc<Config>>,
kv_index: Option<Arc<KvEventIndex>>, kv_index: Option<Arc<KvEventIndex>>,
active_load: Option<Arc<ActiveLoadRegistry>>, router_inflight_load: Option<Arc<RouterInflightLoadRegistry>>,
) { ) {
run_with_introspector( run_with_introspector(
rx, rx,
registry, registry,
cfg, cfg,
kv_index, kv_index,
active_load, router_inflight_load,
Arc::new(WorkerIntrospector::default()), Arc::new(WorkerIntrospector::default()),
) )
.await .await
@@ -146,7 +146,7 @@ pub async fn run_with_introspector(
registry: Arc<WorkerRegistry>, registry: Arc<WorkerRegistry>,
cfg: Option<Arc<Config>>, cfg: Option<Arc<Config>>,
kv_index: Option<Arc<KvEventIndex>>, kv_index: Option<Arc<KvEventIndex>>,
active_load: Option<Arc<ActiveLoadRegistry>>, router_inflight_load: Option<Arc<RouterInflightLoadRegistry>>,
introspector: Arc<WorkerIntrospector>, introspector: Arc<WorkerIntrospector>,
) { ) {
run_with_introspector_and_reconcile( run_with_introspector_and_reconcile(
@@ -154,7 +154,7 @@ pub async fn run_with_introspector(
registry, registry,
cfg, cfg,
kv_index, kv_index,
active_load, router_inflight_load,
introspector, introspector,
RECONCILE_INTERVAL, RECONCILE_INTERVAL,
) )
@@ -186,7 +186,7 @@ pub async fn run_with_introspector_and_reconcile(
registry: Arc<WorkerRegistry>, registry: Arc<WorkerRegistry>,
cfg: Option<Arc<Config>>, cfg: Option<Arc<Config>>,
kv_index: Option<Arc<KvEventIndex>>, kv_index: Option<Arc<KvEventIndex>>,
active_load: Option<Arc<ActiveLoadRegistry>>, router_inflight_load: Option<Arc<RouterInflightLoadRegistry>>,
introspector: Arc<WorkerIntrospector>, introspector: Arc<WorkerIntrospector>,
reconcile_interval: Duration, reconcile_interval: Duration,
) { ) {
@@ -226,7 +226,7 @@ pub async fn run_with_introspector_and_reconcile(
&registry, &registry,
&cfg, &cfg,
&kv_index, &kv_index,
&active_load, &router_inflight_load,
&introspector, &introspector,
&mut pending, &mut pending,
) )
@@ -264,7 +264,7 @@ async fn handle_discovery_event(
registry: &Arc<WorkerRegistry>, registry: &Arc<WorkerRegistry>,
cfg: &Option<Arc<Config>>, cfg: &Option<Arc<Config>>,
kv_index: &Option<Arc<KvEventIndex>>, kv_index: &Option<Arc<KvEventIndex>>,
active_load: &Option<Arc<ActiveLoadRegistry>>, router_inflight_load: &Option<Arc<RouterInflightLoadRegistry>>,
introspector: &Arc<WorkerIntrospector>, introspector: &Arc<WorkerIntrospector>,
pending: &mut HashMap<WorkerId, JoinHandle<()>>, pending: &mut HashMap<WorkerId, JoinHandle<()>>,
) { ) {
@@ -327,7 +327,7 @@ async fn handle_discovery_event(
// per-worker counters slot will not be re-created // per-worker counters slot will not be re-created
// (selectors no longer see the worker, so no new // (selectors no longer see the worker, so no new
// requests can register against it). // requests can register against it).
if let Some(al) = active_load { if let Some(al) = router_inflight_load {
al.forget_worker(&id); al.forget_worker(&id);
} }
} }
@@ -570,7 +570,7 @@ async fn register_one(
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{ use crate::config::{
ActiveLoadConfig, CircuitBreakerConfig as RawCbConfig, DiscoveryBackend, ModelConfig, CircuitBreakerConfig as RawCbConfig, DiscoveryBackend, InflightLoadConfig, ModelConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use crate::discovery::{WorkerId, WorkerMode}; use crate::discovery::{WorkerId, WorkerMode};
@@ -610,7 +610,7 @@ mod tests {
urls: vec!["http://test:30000".into()], urls: vec!["http://test:30000".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -970,7 +970,7 @@ mod tests {
} }
/// Task B: `DiscoveryEvent::Removed` calls /// Task B: `DiscoveryEvent::Removed` calls
/// `ActiveLoadRegistry::forget_worker` so the per-worker counters /// `RouterInflightLoadRegistry::forget_worker` so the per-worker counters
/// slot is reaped. Without this, a long-lived cluster with worker /// slot is reaped. Without this, a long-lived cluster with worker
/// churn would leak one `WorkerCounters` entry per departed worker. /// churn would leak one `WorkerCounters` entry per departed worker.
#[tokio::test] #[tokio::test]
@@ -984,14 +984,14 @@ mod tests {
spawn_fake_server_info_worker(json!({"served_model_name": "m"})).await; spawn_fake_server_info_worker(json!({"served_model_name": "m"})).await;
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
let active_load = ActiveLoadRegistry::with_defaults(); let router_inflight_load = RouterInflightLoadRegistry::with_defaults();
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8); let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_introspector( let manager_handle = tokio::spawn(run_with_introspector(
rx, rx,
registry.clone(), registry.clone(),
None, None,
None, None,
Some(Arc::clone(&active_load)), Some(Arc::clone(&router_inflight_load)),
fast_introspector(), fast_introspector(),
)); ));
@@ -1019,8 +1019,8 @@ mod tests {
// Mint a guard to force the active-load registry to create a // Mint a guard to force the active-load registry to create a
// per-worker counters slot for this id. // per-worker counters slot for this id.
let _g = active_load.register(id.clone(), "test://", 10, 1); let _g = router_inflight_load.register(id.clone(), "test://", 10, 1);
assert!(active_load.is_known(&id)); assert!(router_inflight_load.is_known(&id));
// Now drive the Removed event and assert the counters slot is // Now drive the Removed event and assert the counters slot is
// gone. We tear down the guard last so the request entry is // gone. We tear down the guard last so the request entry is
@@ -1030,7 +1030,7 @@ mod tests {
.unwrap(); .unwrap();
let removed = timeout(Duration::from_secs(2), async { let removed = timeout(Duration::from_secs(2), async {
loop { loop {
if !active_load.is_known(&id) && registry.get(&id).is_none() { if !router_inflight_load.is_known(&id) && registry.get(&id).is_none() {
return true; return true;
} }
tokio::time::sleep(Duration::from_millis(20)).await; tokio::time::sleep(Duration::from_millis(20)).await;
@@ -1039,7 +1039,7 @@ mod tests {
.await; .await;
assert!( assert!(
removed.is_ok(), removed.is_ok(),
"manager must call active_load.forget_worker on Removed", "manager must call router_inflight_load.forget_worker on Removed",
); );
drop(tx); drop(tx);
@@ -77,7 +77,7 @@ impl WorkerRegistry {
/// spec carries an id that already has an entry, the prior entry /// spec carries an id that already has an entry, the prior entry
/// stays put — it's the caller's responsibility to decide whether /// stays put — it's the caller's responsibility to decide whether
/// to evict it (and, importantly, to also clean up sidecar state /// to evict it (and, importantly, to also clean up sidecar state
/// in `KvEventIndex` / `ActiveLoadRegistry` if so). Doing that /// in `KvEventIndex` / `RouterInflightLoadRegistry` if so). Doing that
/// cleanup here would leak orphan state into those sidecars when /// cleanup here would leak orphan state into those sidecars when
/// a caller actually wanted to keep the prior entry. /// a caller actually wanted to keep the prior entry.
pub fn add_with_cb( pub fn add_with_cb(
@@ -463,7 +463,7 @@ mod tests {
/// On a rejected upsert with `MixedPdAndPlain`, the registry is /// On a rejected upsert with `MixedPdAndPlain`, the registry is
/// **not** mutated — the prior entry for the rejected id stays /// **not** mutated — the prior entry for the rejected id stays
/// put. Eviction (with the matching `KvEventIndex` / /// put. Eviction (with the matching `KvEventIndex` /
/// `ActiveLoadRegistry` cleanup) is the manager's responsibility; /// `RouterInflightLoadRegistry` cleanup) is the manager's responsibility;
/// doing it here would leak orphan state in those sidecars. /// doing it here would leak orphan state in those sidecars.
#[test] #[test]
fn upsert_rejected_with_mixed_modes_leaves_registry_unchanged() { fn upsert_rejected_with_mixed_modes_leaves_registry_unchanged() {
+12 -12
View File
@@ -246,7 +246,7 @@ impl Worker {
self.protocol self.protocol
} }
pub fn active_load(&self) -> usize { pub fn router_inflight_load(&self) -> usize {
self.active_requests.load(Ordering::Relaxed) self.active_requests.load(Ordering::Relaxed)
} }
@@ -284,7 +284,7 @@ impl std::fmt::Debug for Worker {
.field("url", &self.url) .field("url", &self.url)
.field("mode", &self.mode()) .field("mode", &self.mode())
.field("protocol", &self.protocol) .field("protocol", &self.protocol)
.field("active_load", &self.active_load()) .field("router_inflight_load", &self.router_inflight_load())
.finish() .finish()
} }
} }
@@ -304,15 +304,15 @@ mod tests {
model_ids: vec![ModelId("m".into())], model_ids: vec![ModelId("m".into())],
bootstrap_port: None, bootstrap_port: None,
}); });
assert_eq!(w.active_load(), 0); assert_eq!(w.router_inflight_load(), 0);
let g = w.load_guard(); let g = w.load_guard();
assert_eq!(w.active_load(), 1); assert_eq!(w.router_inflight_load(), 1);
let g2 = w.load_guard(); let g2 = w.load_guard();
assert_eq!(w.active_load(), 2); assert_eq!(w.router_inflight_load(), 2);
drop(g); drop(g);
assert_eq!(w.active_load(), 1); assert_eq!(w.router_inflight_load(), 1);
drop(g2); drop(g2);
assert_eq!(w.active_load(), 0); assert_eq!(w.router_inflight_load(), 0);
} }
#[test] #[test]
@@ -321,11 +321,11 @@ mod tests {
let cutoff = Instant::now() - Duration::from_secs(1); let cutoff = Instant::now() - Duration::from_secs(1);
let guard = w.load_guard(); let guard = w.load_guard();
assert_eq!(w.active_load(), 1); assert_eq!(w.router_inflight_load(), 1);
assert_eq!(w.slots_acquired_since(cutoff), 0); assert_eq!(w.slots_acquired_since(cutoff), 0);
drop(guard); drop(guard);
assert_eq!(w.active_load(), 0); assert_eq!(w.router_inflight_load(), 0);
} }
#[test] #[test]
@@ -334,11 +334,11 @@ mod tests {
let cutoff = Instant::now() - Duration::from_secs(1); let cutoff = Instant::now() - Duration::from_secs(1);
let guard = w.timestamped_load_guard(); let guard = w.timestamped_load_guard();
assert_eq!(w.active_load(), 1); assert_eq!(w.router_inflight_load(), 1);
assert_eq!(w.slots_acquired_since(cutoff), 1); assert_eq!(w.slots_acquired_since(cutoff), 1);
drop(guard); drop(guard);
assert_eq!(w.active_load(), 0); assert_eq!(w.router_inflight_load(), 0);
assert_eq!(w.slots_acquired_since(cutoff), 0); assert_eq!(w.slots_acquired_since(cutoff), 0);
} }
@@ -477,7 +477,7 @@ mod tests {
let cutoff = Instant::now(); let cutoff = Instant::now();
let _g_new1 = w.timestamped_load_guard(); let _g_new1 = w.timestamped_load_guard();
let _g_new2 = w.timestamped_load_guard(); let _g_new2 = w.timestamped_load_guard();
assert_eq!(w.active_load(), 3); assert_eq!(w.router_inflight_load(), 3);
assert_eq!( assert_eq!(
w.slots_acquired_since(cutoff), w.slots_acquired_since(cutoff),
2, 2,
@@ -90,7 +90,8 @@ async fn static_urls_pd_role_resolved_end_to_end() {
use axum::{routing::get, Json, Router}; use axum::{routing::get, Json, Router};
use serde_json::json; use serde_json::json;
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ObservabilityConfig, ProxyConfig, ServerConfig, Config, DiscoveryBackend, InflightLoadConfig, ObservabilityConfig, ProxyConfig,
ServerConfig,
}; };
use sgl_router::discovery::{spawn_discovery, WorkerId}; use sgl_router::discovery::{spawn_discovery, WorkerId};
use sgl_router::workers::{manager, WorkerRegistry}; use sgl_router::workers::{manager, WorkerRegistry};
@@ -146,7 +147,7 @@ async fn static_urls_pd_role_resolved_end_to_end() {
urls: vec![url.clone()], urls: vec![url.clone()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
}; };
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
@@ -5,10 +5,8 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use sgl_kv_indexer::PrefixOutcome; use sgl_kv_indexer::PrefixOutcome;
use sgl_router::policies::kv_events::{
compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId,
};
use sgl_router::policies::prefix_provider::RadixTreePrefixProvider; use sgl_router::policies::prefix_provider::RadixTreePrefixProvider;
use sgl_router::state::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId};
#[test] #[test]
fn radix_tree_reports_contiguous_prefix_depth_per_worker() { fn radix_tree_reports_contiguous_prefix_depth_per_worker() {
@@ -12,8 +12,10 @@ use sgl_router::policies::decode::{
resolve_decode_with_capacity_fallback, DecodePolicy, DecodePowerOfTwoPolicy, resolve_decode_with_capacity_fallback, DecodePolicy, DecodePowerOfTwoPolicy,
DecodeSelectionContext, LegacyHostAffinityDecodePolicy, DecodeSelectionContext, LegacyHostAffinityDecodePolicy,
}; };
use sgl_router::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
use sgl_router::policies::SelectionProposal; use sgl_router::policies::SelectionProposal;
use sgl_router::state::load_monitor::engine_reported_load::{
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
};
use sgl_router::workers::Worker; use sgl_router::workers::Worker;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -30,15 +32,15 @@ fn worker(id: &str) -> Arc<Worker> {
})) }))
} }
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot { fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers( EngineReportedLoadSnapshot::from_native_cache_workers(
7, 7,
entries entries
.iter() .iter()
.map(|(worker, running, waiting, used, capacity)| { .map(|(worker, running, waiting, used, capacity)| {
( (
worker.url.clone(), worker.url.clone(),
NativeCacheWorkerLoad { EngineReportedSchedulingLoad {
num_running_reqs: *running, num_running_reqs: *running,
num_waiting_reqs: *waiting, num_waiting_reqs: *waiting,
num_waiting_uncached_tokens: *waiting, num_waiting_uncached_tokens: *waiting,
@@ -12,15 +12,15 @@
//! and the idlest at 1.0, so every assertion below holds either way. //! and the idlest at 1.0, so every assertion below holds either way.
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad};
use sgl_router::policies::kv_events::{
compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId,
};
use sgl_router::policies::load_based::LoadBasedPolicy; use sgl_router::policies::load_based::LoadBasedPolicy;
use sgl_router::policies::scoring::{ use sgl_router::policies::scoring::{
prefix_cache::PrefixCachePolicy, FusedScorePolicy, ScorePolicy, prefix_cache::PrefixCachePolicy, FusedScorePolicy, ScorePolicy,
}; };
use sgl_router::policies::{Policy, SelectionContext}; use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::state::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId};
use sgl_router::state::load_monitor::engine_reported_load::{
EngineReportedLoadSnapshot, EngineReportedWorkerLoad,
};
use sgl_router::workers::Worker; use sgl_router::workers::Worker;
use std::{collections::HashMap, sync::Arc, time::Instant}; use std::{collections::HashMap, sync::Arc, time::Instant};
@@ -82,12 +82,12 @@ fn fused_load_based_term_uses_the_request_snapshot() {
let ws = vec![worker("w0"), worker("w1")]; let ws = vec![worker("w0"), worker("w1")];
// Local counters changed after the request snapshot and prefer w0. // Local counters changed after the request snapshot and prefer w0.
let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect(); let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect();
let snapshot = EngineLoadSnapshot::from_workers( let snapshot = EngineReportedLoadSnapshot::from_workers(
29, 29,
HashMap::from([ HashMap::from([
( (
ws[0].url.clone(), ws[0].url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 50, num_running_reqs: 50,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -97,7 +97,7 @@ fn fused_load_based_term_uses_the_request_snapshot() {
), ),
( (
ws[1].url.clone(), ws[1].url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 1, num_running_reqs: 1,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -123,12 +123,12 @@ fn fused_load_based_term_uses_the_request_snapshot() {
fn score_policy_forwards_the_request_snapshot_to_load_based() { fn score_policy_forwards_the_request_snapshot_to_load_based() {
let ws = vec![worker("w0"), worker("w1")]; let ws = vec![worker("w0"), worker("w1")];
let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect(); let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect();
let snapshot = EngineLoadSnapshot::from_workers( let snapshot = EngineReportedLoadSnapshot::from_workers(
31, 31,
HashMap::from([ HashMap::from([
( (
ws[0].url.clone(), ws[0].url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 50, num_running_reqs: 50,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -138,7 +138,7 @@ fn score_policy_forwards_the_request_snapshot_to_load_based() {
), ),
( (
ws[1].url.clone(), ws[1].url.clone(),
EngineWorkerLoad { EngineReportedWorkerLoad {
num_running_reqs: 1, num_running_reqs: 1,
num_waiting_reqs: 0, num_waiting_reqs: 0,
num_tokens: 0, num_tokens: 0,
@@ -19,7 +19,7 @@
//! whatever fixture is checked in. //! whatever fixture is checked in.
use serde::Deserialize; use serde::Deserialize;
use sgl_router::policies::kv_events::compute_block_hashes; use sgl_router::state::kv_events::compute_block_hashes;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -3,7 +3,7 @@
//! Concurrent-mutation stress test for `HashTree`. //! Concurrent-mutation stress test for `HashTree`.
//! //!
//! The inline tests in `policies::kv_events::tree` are all //! The inline tests in `state::kv_events::tree` are all
//! single-threaded. Under production load, multiple worker subscribers //! single-threaded. Under production load, multiple worker subscribers
//! drive `insert` / `remove` / `clear_worker` against the same tree from //! drive `insert` / `remove` / `clear_worker` against the same tree from
//! tokio worker threads while the chat handler simultaneously calls //! tokio worker threads while the chat handler simultaneously calls
@@ -25,7 +25,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use sgl_router::policies::kv_events::{HashTree, KvWorkerId}; use sgl_router::state::kv_events::{HashTree, KvWorkerId};
fn worker(i: usize) -> KvWorkerId { fn worker(i: usize) -> KvWorkerId {
KvWorkerId { KvWorkerId {
@@ -22,8 +22,8 @@ use std::time::Duration;
use zeromq::SocketSend; use zeromq::SocketSend;
use sgl_router::policies::kv_events::discovery::EventConfig; use sgl_router::state::kv_events::discovery::EventConfig;
use sgl_router::policies::kv_events::{compute_block_hashes, KvEventIndex, KvWorkerId}; use sgl_router::state::kv_events::{compute_block_hashes, KvEventIndex, KvWorkerId};
use super::zmq_helpers::{ use super::zmq_helpers::{
build_multipart, encode_block_stored_event, encode_event_batch, make_pub_bound, build_multipart, encode_block_stored_event, encode_event_batch, make_pub_bound,
@@ -1,7 +1,7 @@
// 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
//! Shared ZMQ wire-format helpers for the `policies::kv_events` component //! Shared ZMQ wire-format helpers for the `state::kv_events` component
//! tests. Encodes events in the same msgspec layout SGLang emits, builds //! tests. Encodes events in the same msgspec layout SGLang emits, builds
//! the two-frame `[seq, payload]` ZMQ message a real publisher sends, and //! the two-frame `[seq, payload]` ZMQ message a real publisher sends, and
//! binds a loopback PUB socket on an OS-assigned port. //! binds a loopback PUB socket on an OS-assigned port.
@@ -6,7 +6,7 @@
use serde::Deserialize; use serde::Deserialize;
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::ModelId; use sgl_router::discovery::ModelId;
@@ -128,7 +128,7 @@ fn registry(model_id: &str, tokenizer_path: PathBuf) -> TokenizerRegistry {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
}; };
TokenizerRegistry::load_from_config(&cfg).unwrap() TokenizerRegistry::load_from_config(&cfg).unwrap()
} }
@@ -131,17 +131,17 @@ fn load_guard_decrements_on_panic_unwind() {
model_ids: vec![ModelId("m".into())], model_ids: vec![ModelId("m".into())],
bootstrap_port: None, bootstrap_port: None,
})); }));
assert_eq!(w.active_load(), 0); assert_eq!(w.router_inflight_load(), 0);
let w_inner = w.clone(); let w_inner = w.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _g = w_inner.load_guard(); let _g = w_inner.load_guard();
assert_eq!(w_inner.active_load(), 1); assert_eq!(w_inner.router_inflight_load(), 1);
panic!("synthetic panic to exercise Drop on unwind"); panic!("synthetic panic to exercise Drop on unwind");
})); }));
assert!(result.is_err(), "the closure must have panicked"); assert!(result.is_err(), "the closure must have panicked");
assert_eq!( assert_eq!(
w.active_load(), w.router_inflight_load(),
0, 0,
"LoadGuard's Drop must decrement even when the holder panics", "LoadGuard's Drop must decrement even when the holder panics",
); );
@@ -740,7 +740,7 @@ async fn removed_awaits_pending_added() {
/// independently — 2N round-trips for N workers. /// independently — 2N round-trips for N workers.
#[tokio::test] #[tokio::test]
async fn manager_emits_single_server_info_fetch_per_worker() { async fn manager_emits_single_server_info_fetch_per_worker() {
use sgl_router::policies::kv_events::KvEventIndex; use sgl_router::state::kv_events::KvEventIndex;
let body = json!({ let body = json!({
"served_model_name": "m", "served_model_name": "m",
@@ -10,17 +10,17 @@ use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use sgl_kv_indexer::{PrefixIndex, PrefixIndexError, PrefixMatch, PrefixOutcome}; use sgl_kv_indexer::{PrefixIndex, PrefixIndexError, PrefixMatch, PrefixOutcome};
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, AffinityConfig, BucketConfig, BucketSpec, BucketStage, CacheAwareConfig, AffinityConfig, BucketConfig, BucketSpec, BucketStage, CacheAwareConfig, CachePrefixProvider,
CachePrefixProvider, Config, DiscoveryBackend, KvIndexerEndpointConfig, ModelConfig, Config, DiscoveryBackend, InflightLoadConfig, KvIndexerEndpointConfig, ModelConfig,
ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode, ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode,
SloBucketPolicy, StaticUrlsDiscoveryConfig, SloBucketPolicy, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::engine_load::{LoadStat, NativeCacheRankLoad};
use sgl_router::policies::factory::build_registry_with_defaults; use sgl_router::policies::factory::build_registry_with_defaults;
use sgl_router::proxy::Proxy; use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router; use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext; use sgl_router::server::app_context::AppContext;
use sgl_router::state::load_monitor::engine_reported_load::{LoadStat, NativeCacheRankLoad};
use sgl_router::tokenizer::TokenizerRegistry; use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry; use sgl_router::workers::WorkerRegistry;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
@@ -77,7 +77,7 @@ fn build_app_context(
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
}; };
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&config).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&config).unwrap());
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
@@ -236,7 +236,7 @@ fn set_native_load_with_waiting(
max_total_num_tokens: u64, max_total_num_tokens: u64,
num_waiting_uncached_tokens: u64, num_waiting_uncached_tokens: u64,
) { ) {
ctx.engine_load.set( ctx.engine_reported_load.set(
worker_url, worker_url,
0, 0,
LoadStat { LoadStat {
@@ -18,10 +18,10 @@ use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry; use sgl_router::policies::factory::build_registry;
use sgl_router::policies::kv_events::{BlockSizeOracle, HashTree};
use sgl_router::proxy::Proxy; use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router; use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext; use sgl_router::server::app_context::AppContext;
use sgl_router::state::kv_events::{BlockSizeOracle, HashTree};
use sgl_router::tokenizer::TokenizerRegistry; use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry; use sgl_router::workers::WorkerRegistry;
use std::sync::Arc; use std::sync::Arc;
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -50,7 +50,7 @@ fn config_for(_worker_url: &str) -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -1322,7 +1322,7 @@ async fn forward_json_to_malformed_url_returns_worker_misconfigured_and_trips_br
/// streaming response, not just for the handler lifetime. /// streaming response, not just for the handler lifetime.
/// ///
/// Before the fix, the handler dropped `_guard` as soon as it returned /// Before the fix, the handler dropped `_guard` as soon as it returned
/// (which happens when headers arrive), so `active_load()` was 0 while /// (which happens when headers arrive), so `router_inflight_load()` was 0 while
/// the SSE pump was still relaying bytes. This test catches that bug. /// the SSE pump was still relaying bytes. This test catches that bug.
#[tokio::test] #[tokio::test]
async fn streaming_load_guard_persists_for_body_lifetime() { async fn streaming_load_guard_persists_for_body_lifetime() {
@@ -1360,7 +1360,7 @@ async fn streaming_load_guard_persists_for_body_lifetime() {
)); ));
let app = build_router(ctx); let app = build_router(ctx);
// Grab the Worker handle so we can assert active_load(). // Grab the Worker handle so we can assert router_inflight_load().
let w_handle: Arc<Worker> = registry let w_handle: Arc<Worker> = registry
.workers_for(&ModelId("tiny".into())) .workers_for(&ModelId("tiny".into()))
.into_iter() .into_iter()
@@ -1386,9 +1386,9 @@ async fn streaming_load_guard_persists_for_body_lifetime() {
// first chunk's delay to pass, then assert load is still held. // first chunk's delay to pass, then assert load is still held.
tokio::time::sleep(Duration::from_millis(20)).await; tokio::time::sleep(Duration::from_millis(20)).await;
assert!( assert!(
w_handle.active_load() >= 1, w_handle.router_inflight_load() >= 1,
"load should be >= 1 mid-stream, got {}", "load should be >= 1 mid-stream, got {}",
w_handle.active_load() w_handle.router_inflight_load()
); );
// Drain the entire body — this drives the SSE pump to completion. // Drain the entire body — this drives the SSE pump to completion.
@@ -1398,25 +1398,25 @@ async fn streaming_load_guard_persists_for_body_lifetime() {
// released. Give the spawned task a brief moment to clean up. // released. Give the spawned task a brief moment to clean up.
tokio::time::sleep(Duration::from_millis(20)).await; tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!( assert_eq!(
w_handle.active_load(), w_handle.router_inflight_load(),
0, 0,
"load should be 0 after stream completes" "load should be 0 after stream completes"
); );
} }
/// Task A: the chat handler mints an `ActiveLoadGuard` from the shared /// Task A: the chat handler mints an `RouterInflightLoadGuard` from the shared
/// `ActiveLoadRegistry` and drops it when the request completes. The /// `RouterInflightLoadRegistry` and drops it when the request completes. The
/// non-streaming path drops the guard on handler exit; this test /// non-streaming path drops the guard on handler exit; this test
/// asserts the round-trip increment → 0 across a single request. /// asserts the round-trip increment → 0 across a single request.
#[tokio::test] #[tokio::test]
async fn non_streaming_active_load_increments_then_returns_to_zero() { async fn non_streaming_active_load_increments_then_returns_to_zero() {
let worker = crate::common::mock_worker::MockWorker::start(vec![]).await; let worker = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx_with_worker(&worker.url); let ctx = build_ctx_with_worker(&worker.url);
let active_load = Arc::clone(&ctx.active_load); let router_inflight_load = Arc::clone(&ctx.router_inflight_load);
let app = build_router(ctx); let app = build_router(ctx);
assert_eq!( assert_eq!(
active_load.inflight_count(), router_inflight_load.inflight_count(),
0, 0,
"registry must start with no in-flight requests", "registry must start with no in-flight requests",
); );
@@ -1442,19 +1442,19 @@ async fn non_streaming_active_load_increments_then_returns_to_zero() {
// The handler has returned, so the active-load guard must have // The handler has returned, so the active-load guard must have
// dropped — counters are back to zero. // dropped — counters are back to zero.
assert_eq!( assert_eq!(
active_load.inflight_count(), router_inflight_load.inflight_count(),
0, 0,
"active-load registry must be empty after non-streaming handler returns", "active-load registry must be empty after non-streaming handler returns",
); );
let w_id = WorkerId("w1".into()); let w_id = WorkerId("w1".into());
assert_eq!( assert_eq!(
active_load.prefill_load(&w_id), router_inflight_load.prefill_load(&w_id),
0, 0,
"prefill_load must decrement on response end", "prefill_load must decrement on response end",
); );
} }
/// Task A: the streaming path holds the `ActiveLoadGuard` until the /// Task A: the streaming path holds the `RouterInflightLoadGuard` until the
/// SSE pump finishes. Mid-stream the registry shows `inflight_count >= 1`; /// SSE pump finishes. Mid-stream the registry shows `inflight_count >= 1`;
/// after the body drains it returns to 0. Counterpart to /// after the body drains it returns to 0. Counterpart to
/// `streaming_load_guard_persists_for_body_lifetime` — both guards must /// `streaming_load_guard_persists_for_body_lifetime` — both guards must
@@ -1486,7 +1486,7 @@ async fn streaming_active_load_persists_for_body_lifetime() {
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(TEST_TIMEOUT).unwrap()); let proxy = Arc::new(Proxy::new(TEST_TIMEOUT).unwrap());
let ctx = Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies)); let ctx = Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies));
let active_load = Arc::clone(&ctx.active_load); let router_inflight_load = Arc::clone(&ctx.router_inflight_load);
let app = build_router(ctx); let app = build_router(ctx);
let req = Request::builder() let req = Request::builder()
@@ -1508,15 +1508,15 @@ async fn streaming_active_load_persists_for_body_lifetime() {
// still running, so the registry's per-request entry must remain. // still running, so the registry's per-request entry must remain.
tokio::time::sleep(Duration::from_millis(20)).await; tokio::time::sleep(Duration::from_millis(20)).await;
assert!( assert!(
active_load.inflight_count() >= 1, router_inflight_load.inflight_count() >= 1,
"registry inflight must be >= 1 mid-stream, got {}", "registry inflight must be >= 1 mid-stream, got {}",
active_load.inflight_count(), router_inflight_load.inflight_count(),
); );
let w_id = WorkerId("w1".into()); let w_id = WorkerId("w1".into());
assert!( assert!(
active_load.prefill_load(&w_id) >= 1, router_inflight_load.prefill_load(&w_id) >= 1,
"prefill_load must be > 0 mid-stream, got {}", "prefill_load must be > 0 mid-stream, got {}",
active_load.prefill_load(&w_id), router_inflight_load.prefill_load(&w_id),
); );
// Drain the body — drives the SSE pump to completion. // Drain the body — drives the SSE pump to completion.
@@ -1524,12 +1524,12 @@ async fn streaming_active_load_persists_for_body_lifetime() {
tokio::time::sleep(Duration::from_millis(20)).await; tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!( assert_eq!(
active_load.inflight_count(), router_inflight_load.inflight_count(),
0, 0,
"registry must be empty after stream drains", "registry must be empty after stream drains",
); );
assert_eq!( assert_eq!(
active_load.prefill_load(&w_id), router_inflight_load.prefill_load(&w_id),
0, 0,
"prefill_load must be 0 after stream drains", "prefill_load must be 0 after stream drains",
); );
@@ -1555,7 +1555,7 @@ async fn streaming_active_load_drops_on_client_disconnect() {
) )
.await; .await;
let (ctx, body) = stream_chat(&worker.url).await; let (ctx, body) = stream_chat(&worker.url).await;
let active_load = Arc::clone(&ctx.active_load); let router_inflight_load = Arc::clone(&ctx.router_inflight_load);
// Read one chunk to confirm the stream is live, then drop the body. // Read one chunk to confirm the stream is live, then drop the body.
use futures::StreamExt; use futures::StreamExt;
@@ -1570,7 +1570,7 @@ async fn streaming_active_load_drops_on_client_disconnect() {
wait_for_metric(&ctx, &expected).await; wait_for_metric(&ctx, &expected).await;
assert_eq!( assert_eq!(
active_load.inflight_count(), router_inflight_load.inflight_count(),
0, 0,
"client disconnect must drop the streaming pump's guards within one tick", "client disconnect must drop the streaming pump's guards within one tick",
); );
@@ -1583,13 +1583,15 @@ async fn streaming_active_load_drops_on_client_disconnect() {
/// `ApiError::StaleRequestExpired`. /// `ApiError::StaleRequestExpired`.
/// ///
/// Wiring: build an `AppContext` with a short /// Wiring: build an `AppContext` with a short
/// `stale_request_timeout` `ActiveLoadRegistry` + spawn a janitor /// `stale_request_timeout` `RouterInflightLoadRegistry` + spawn a janitor
/// with sub-second cadence + dispatch to a slow upstream that takes /// with sub-second cadence + dispatch to a slow upstream that takes
/// longer than the timeout. The janitor sweeps before the upstream /// longer than the timeout. The janitor sweeps before the upstream
/// returns; cancellation fires; handler returns 504. /// returns; cancellation fires; handler returns 504.
#[tokio::test] #[tokio::test]
async fn janitor_expiry_returns_504_stale_request_expired() { async fn janitor_expiry_returns_504_stale_request_expired() {
use sgl_router::policies::active_load::{spawn_janitor, ActiveLoadRegistry}; use sgl_router::state::load_monitor::router_inflight_load::{
spawn_janitor, RouterInflightLoadRegistry,
};
// Upstream that takes 2s to respond — longer than our 50ms // Upstream that takes 2s to respond — longer than our 50ms
// stale_request_timeout. // stale_request_timeout.
let worker = let worker =
@@ -1610,18 +1612,18 @@ async fn janitor_expiry_returns_504_stale_request_expired() {
// Aggressive 50ms timeout: the janitor will sweep on the next // Aggressive 50ms timeout: the janitor will sweep on the next
// tick (every 20ms) and fire the cancellation token before the // tick (every 20ms) and fire the cancellation token before the
// upstream returns. // upstream returns.
let active_load = ActiveLoadRegistry::new( let router_inflight_load = RouterInflightLoadRegistry::new(
Arc::new(sgl_router::policies::active_load::SystemTimeClock), Arc::new(sgl_router::state::load_monitor::router_inflight_load::SystemTimeClock),
Duration::from_millis(50), Duration::from_millis(50),
); );
let _janitor = spawn_janitor(Arc::clone(&active_load), Duration::from_millis(20)); let _janitor = spawn_janitor(Arc::clone(&router_inflight_load), Duration::from_millis(20));
let ctx = Arc::new(AppContext::with_active_load( let ctx = Arc::new(AppContext::with_router_inflight_load(
cfg, cfg,
tokenizers, tokenizers,
proxy, proxy,
registry, registry,
policies, policies,
active_load, router_inflight_load,
)); ));
let app = build_router(ctx); let app = build_router(ctx);
@@ -1671,7 +1673,7 @@ async fn non_streaming_error_path_drops_active_load_guard() {
drop(listener); drop(listener);
let ctx = build_ctx_with_worker(&dead_url); let ctx = build_ctx_with_worker(&dead_url);
let active_load = Arc::clone(&ctx.active_load); let router_inflight_load = Arc::clone(&ctx.router_inflight_load);
let app = build_router(ctx); let app = build_router(ctx);
let req = Request::builder() let req = Request::builder()
@@ -1692,7 +1694,7 @@ async fn non_streaming_error_path_drops_active_load_guard() {
// Drain so any drop-on-body-end work runs. // Drain so any drop-on-body-end work runs.
let _ = res.into_body().collect().await.unwrap().to_bytes(); let _ = res.into_body().collect().await.unwrap().to_bytes();
assert_eq!( assert_eq!(
active_load.inflight_count(), router_inflight_load.inflight_count(),
0, 0,
"error path must drop the active-load guard", "error path must drop the active-load guard",
); );
@@ -7,8 +7,8 @@
//! built-in V4 chat formatter — the engine-equivalent path — with no template fixture. //! built-in V4 chat formatter — the engine-equivalent path — with no template fixture.
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, CacheAwareConfig, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
pub const MODEL: &str = "deepseek-v4-tiny"; pub const MODEL: &str = "deepseek-v4-tiny";
@@ -42,6 +42,6 @@ pub fn config() -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -19,11 +19,11 @@ use sgl_kv_indexer::{
use sgl_router::config::{AffinityConfig, CachePrefixProvider, PolicyKind}; use sgl_router::config::{AffinityConfig, CachePrefixProvider, PolicyKind};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry; use sgl_router::policies::factory::build_registry;
use sgl_router::policies::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree};
use sgl_router::policies::request_tokens_for; use sgl_router::policies::request_tokens_for;
use sgl_router::proxy::Proxy; use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router; use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext; use sgl_router::server::app_context::AppContext;
use sgl_router::state::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree};
use sgl_router::tokenizer::TokenizerRegistry; use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry; use sgl_router::workers::WorkerRegistry;
use tokio_stream::wrappers::TcpListenerStream; use tokio_stream::wrappers::TcpListenerStream;
@@ -54,7 +54,7 @@ async fn failover_when_one_worker_dies() {
urls: vec![w1.url.clone(), w2.url.clone(), w3.url.clone()], urls: vec![w1.url.clone(), w2.url.clone(), w3.url.clone()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
}; };
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
@@ -25,7 +25,7 @@
use futures::future::join_all; use futures::future::join_all;
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -69,7 +69,7 @@ fn build_ctx_with_worker(worker_url: &str) -> Arc<AppContext> {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
}; };
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
@@ -554,7 +554,7 @@ async fn wait_for_inflight_http(ctx: &Arc<AppContext>, want: usize) {
/// response BODY finishing, not the handler returning. A streaming completion /// response BODY finishing, not the handler returning. A streaming completion
/// hands back its headers immediately, so a count released at handler exit /// hands back its headers immediately, so a count released at handler exit
/// would read 0 for the entire window the heartbeat exists to explain — the /// would read 0 for the entire window the heartbeat exists to explain — the
/// same blind spot `active_load.inflight_count()` has, reproduced in the /// same blind spot `router_inflight_load.inflight_count()` has, reproduced in the
/// replacement. /// replacement.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn inflight_http_counts_a_streaming_response_until_its_body_finishes() { async fn inflight_http_counts_a_streaming_response_until_its_body_finishes() {
@@ -623,7 +623,7 @@ async fn inflight_http_counts_a_streaming_response_until_its_body_finishes() {
/// Every route is instrumented, not only the proxied ones. `/metrics`, /// Every route is instrumented, not only the proxied ones. `/metrics`,
/// `/readyz` and a 404 are exchanges axum's drain waits on too, and they are /// `/readyz` and a 404 are exchanges axum's drain waits on too, and they are
/// exactly the traffic `active_load` cannot see — so a guard that leaked on a /// exactly the traffic `router_inflight_load` cannot see — so a guard that leaked on a
/// non-proxied route would leave the heartbeat permanently busy and turn the /// non-proxied route would leave the heartbeat permanently busy and turn the
/// drain report back into noise. /// drain report back into noise.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -4,7 +4,7 @@
use axum::body::Body; use axum::body::Body;
use axum::http::Request; use axum::http::Request;
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -47,7 +47,7 @@ async fn forwards_whitelisted_headers_strips_others() {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
}; };
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
@@ -21,7 +21,7 @@ use axum::http::{Request, StatusCode};
use bytes::Bytes; use bytes::Bytes;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -62,7 +62,7 @@ fn config() -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -225,7 +225,7 @@ async fn round_robin_pd_prefill_does_not_track_dispatch_timestamps() {
let request = tokio::spawn(build_router(Arc::clone(&ctx)).oneshot(chat_request())); let request = tokio::spawn(build_router(Arc::clone(&ctx)).oneshot(chat_request()));
await_captured_body(&prefill, Duration::from_secs(2), "prefill").await; await_captured_body(&prefill, Duration::from_secs(2), "prefill").await;
assert_eq!(prefill_worker.active_load(), 1); assert_eq!(prefill_worker.router_inflight_load(), 1);
assert_eq!(prefill_worker.slots_acquired_since(cutoff), 0); assert_eq!(prefill_worker.slots_acquired_since(cutoff), 0);
assert_eq!(request.await.unwrap().unwrap().status(), StatusCode::OK); assert_eq!(request.await.unwrap().unwrap().status(), StatusCode::OK);
@@ -20,7 +20,7 @@ use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -61,7 +61,7 @@ fn config() -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -32,7 +32,7 @@ use hyper::service::service_fn;
use hyper::Response as HyperResponse; use hyper::Response as HyperResponse;
use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::rt::{TokioExecutor, TokioIo};
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -149,7 +149,7 @@ fn config() -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -10,14 +10,12 @@ use serde_json::json;
use sgl_router::config::{AffinityConfig, CachePrefixProvider, PolicyKind}; use sgl_router::config::{AffinityConfig, CachePrefixProvider, PolicyKind};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry; use sgl_router::policies::factory::build_registry;
use sgl_router::policies::kv_events::{
compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId,
};
use sgl_router::policies::prefix_provider::RadixTreePrefixProvider; use sgl_router::policies::prefix_provider::RadixTreePrefixProvider;
use sgl_router::policies::request_tokens_for; use sgl_router::policies::request_tokens_for;
use sgl_router::proxy::Proxy; use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router; use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext; use sgl_router::server::app_context::AppContext;
use sgl_router::state::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId};
use sgl_router::tokenizer::TokenizerRegistry; use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry; use sgl_router::workers::WorkerRegistry;
use tower::ServiceExt; use tower::ServiceExt;
@@ -11,7 +11,7 @@ use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -59,7 +59,7 @@ fn config() -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -7,11 +7,10 @@ use std::time::{Duration, Instant};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::engine_load::{LoadStat, NativeCacheRankLoad};
use sgl_router::policies::{ use sgl_router::policies::{
CacheCandidate, CacheCandidateProposal, Policy, PolicyRegistry, PrefillProposal, ProposalKind, CacheCandidate, CacheCandidateProposal, Policy, PolicyRegistry, PrefillProposal, ProposalKind,
SelectionContext, SelectionProposal, SelectionContext, SelectionProposal,
@@ -19,6 +18,7 @@ use sgl_router::policies::{
use sgl_router::proxy::Proxy; use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router; use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext; use sgl_router::server::app_context::AppContext;
use sgl_router::state::load_monitor::engine_reported_load::{LoadStat, NativeCacheRankLoad};
use sgl_router::tokenizer::TokenizerRegistry; use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::{Worker, WorkerRegistry}; use sgl_router::workers::{Worker, WorkerRegistry};
use tower::ServiceExt; use tower::ServiceExt;
@@ -165,7 +165,7 @@ fn config(policy: PolicyKind) -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -307,7 +307,7 @@ async fn chat_commits_the_admitted_prefill_backup() {
total_prefill_busy_us, total_prefill_busy_us,
}), }),
}; };
fixture.ctx.engine_load.set( fixture.ctx.engine_reported_load.set(
&fixture.workers[0].url, &fixture.workers[0].url,
0, 0,
native_load(1, 1), native_load(1, 1),
@@ -315,7 +315,7 @@ async fn chat_commits_the_admitted_prefill_backup() {
); );
fixture fixture
.ctx .ctx
.engine_load .engine_reported_load
.set(&fixture.workers[0].url, 0, native_load(2, 2), now); .set(&fixture.workers[0].url, 0, native_load(2, 2), now);
assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK); assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK);
@@ -351,7 +351,7 @@ async fn capacity_exhaustion_does_not_return_503() {
}) })
.await; .await;
for worker in &fixture.workers { for worker in &fixture.workers {
fixture.ctx.engine_load.set( fixture.ctx.engine_reported_load.set(
&worker.url, &worker.url,
0, 0,
LoadStat { LoadStat {
@@ -434,7 +434,7 @@ async fn chat_records_cache_candidates_exhausted() {
}) })
}) })
.await; .await;
fixture.ctx.engine_load.set( fixture.ctx.engine_reported_load.set(
&fixture.workers[0].url, &fixture.workers[0].url,
0, 0,
LoadStat { LoadStat {
@@ -24,7 +24,7 @@ use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -77,7 +77,7 @@ fn config() -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }
@@ -7,7 +7,7 @@
//! `MockWorker` backends (CPU-only, no GPU). //! `MockWorker` backends (CPU-only, no GPU).
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -63,7 +63,7 @@ fn build_sticky_ctx(header_name: &str, worker_urls: &[String]) -> Arc<AppContext
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
}; };
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
@@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
}; };
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec}; use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
@@ -55,7 +55,7 @@ fn config(_worker_url: &str) -> Config {
urls: vec!["http://placeholder:0".into()], urls: vec!["http://placeholder:0".into()],
}), }),
proxy: ProxyConfig::default(), proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(), router_inflight_load: InflightLoadConfig::default(),
} }
} }