[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:
co-authored by
Claude Fable 5.1
parent
acd20a516e
commit
4a9dc5c4af
@@ -35,7 +35,7 @@ use std::thread;
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use rand::rngs::StdRng;
|
||||
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 {
|
||||
let tree = HashTree::new();
|
||||
|
||||
@@ -10,10 +10,10 @@ use std::num::NonZeroU32;
|
||||
use crate::config::sampling::{parse_sampling_overrides, ConflictPolicy};
|
||||
use crate::config::{
|
||||
default_cb_cool_down, default_host, default_port, default_proxy_request_timeout_secs,
|
||||
default_shutdown_drain_secs, default_stale_request_timeout_secs, resolve_mode,
|
||||
ActiveLoadConfig, AffinityConfig, AffinityMode, CacheAwareConfig, CachePrefixProvider,
|
||||
CircuitBreakerConfig, Config, DecodePolicyKind, DiscoveryBackend, EligibilityConfig,
|
||||
FilterKind, FusedTerm, K8sDiscoveryConfig, KvIndexerEndpointConfig, LogFormat, ModelConfig,
|
||||
default_shutdown_drain_secs, default_stale_request_timeout_secs, resolve_mode, AffinityConfig,
|
||||
AffinityMode, CacheAwareConfig, CachePrefixProvider, CircuitBreakerConfig, Config,
|
||||
DecodePolicyKind, DiscoveryBackend, EligibilityConfig, FilterKind, FusedTerm,
|
||||
InflightLoadConfig, K8sDiscoveryConfig, KvIndexerEndpointConfig, LogFormat, ModelConfig,
|
||||
ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode,
|
||||
StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind, DEFAULT_FUSE,
|
||||
};
|
||||
@@ -390,7 +390,7 @@ impl Cli {
|
||||
proxy: ProxyConfig {
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -906,7 +906,7 @@ mod tests {
|
||||
assert_eq!(c.model.policy, PolicyKind::RoundRobin);
|
||||
assert_eq!(c.model.id, "qwen3-0.6b");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1473,7 +1473,7 @@ mod tests {
|
||||
]))
|
||||
.unwrap();
|
||||
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]
|
||||
|
||||
@@ -237,7 +237,7 @@ mod tests {
|
||||
urls: urls.iter().map(|s| s.to_string()).collect(),
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct Config {
|
||||
/// Discovery mode resolved from CLI options; static URLs are checked by [`Config::validate`].
|
||||
pub discovery: DiscoveryBackend,
|
||||
pub proxy: ProxyConfig,
|
||||
pub active_load: ActiveLoadConfig,
|
||||
pub router_inflight_load: InflightLoadConfig,
|
||||
}
|
||||
|
||||
/// Outbound request timeout settings.
|
||||
@@ -35,7 +35,7 @@ impl Default for ProxyConfig {
|
||||
|
||||
/// Request-tracking timeout; defaults above the proxy timeout.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ActiveLoadConfig {
|
||||
pub struct InflightLoadConfig {
|
||||
/// Maximum request-entry lifetime before cancellation with 504 `stale_request_expired`.
|
||||
pub stale_request_timeout_secs: u64,
|
||||
}
|
||||
@@ -44,7 +44,7 @@ pub fn default_stale_request_timeout_secs() -> u64 {
|
||||
600
|
||||
}
|
||||
|
||||
impl Default for ActiveLoadConfig {
|
||||
impl Default for InflightLoadConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stale_request_timeout_secs: default_stale_request_timeout_secs(),
|
||||
|
||||
@@ -1022,7 +1022,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `process_events` snapshots would dedup by `addr:port` and the
|
||||
/// new pod would inherit the dead pod's state.
|
||||
|
||||
@@ -14,5 +14,6 @@ pub mod health;
|
||||
pub mod policies;
|
||||
pub mod proxy;
|
||||
pub mod server;
|
||||
pub mod state;
|
||||
pub mod tokenizer;
|
||||
pub mod workers;
|
||||
|
||||
@@ -8,14 +8,17 @@ use sgl_router::{
|
||||
config::{CachePrefixProvider, Cli, Config, KvIndexerEndpointConfig, LogFormat, PolicyKind},
|
||||
discovery::spawn_discovery,
|
||||
policies::{
|
||||
active_load::{spawn_janitor, ActiveLoadRegistry, JanitorHandle, SystemTimeClock},
|
||||
factory::build_registry as build_policy_registry,
|
||||
kv_events::{BlockSizeOracle, KvEventIndex},
|
||||
prefix_provider::RadixTreePrefixProvider,
|
||||
factory::build_registry as build_policy_registry, prefix_provider::RadixTreePrefixProvider,
|
||||
PolicyRegistry,
|
||||
},
|
||||
proxy::Proxy,
|
||||
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,
|
||||
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) {
|
||||
let timeout_secs = config.active_load.stale_request_timeout_secs;
|
||||
let local_inflight_requests =
|
||||
ActiveLoadRegistry::new(Arc::new(SystemTimeClock), Duration::from_secs(timeout_secs));
|
||||
fn start_local_inflight_tracker(
|
||||
config: &Config,
|
||||
) -> (Arc<RouterInflightLoadRegistry>, JanitorHandle) {
|
||||
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 1–60 seconds.
|
||||
let sweep_interval = Duration::from_secs((timeout_secs / 10).clamp(1, 60));
|
||||
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,
|
||||
worker_registry: &Arc<WorkerRegistry>,
|
||||
engine_state: &Arc<KvEventIndex>,
|
||||
local_inflight_requests: &Arc<ActiveLoadRegistry>,
|
||||
local_inflight_requests: &Arc<RouterInflightLoadRegistry>,
|
||||
) -> Result<(JoinHandle<()>, JoinHandle<()>)> {
|
||||
let (worker_events, discovery_handle) =
|
||||
spawn_discovery(config).await.context("spawn discovery")?;
|
||||
@@ -262,7 +269,7 @@ fn build_app_context(
|
||||
tokenizers: Arc<TokenizerRegistry>,
|
||||
worker_registry: Arc<WorkerRegistry>,
|
||||
routing_policies: Arc<PolicyRegistry>,
|
||||
local_inflight_requests: Arc<ActiveLoadRegistry>,
|
||||
local_inflight_requests: Arc<RouterInflightLoadRegistry>,
|
||||
engine_state: &KvEventIndex,
|
||||
external_kv_indexer_client: Option<Arc<dyn PrefixIndex>>,
|
||||
) -> Result<Arc<AppContext>> {
|
||||
@@ -272,7 +279,7 @@ fn build_app_context(
|
||||
.context("build proxy client")?,
|
||||
);
|
||||
|
||||
let mut app_context = AppContext::with_active_load(
|
||||
let mut app_context = AppContext::with_router_inflight_load(
|
||||
config.clone(),
|
||||
tokenizers,
|
||||
proxy,
|
||||
@@ -289,7 +296,7 @@ fn build_app_context(
|
||||
.is_some_and(|cache| cache.prefix_provider == CachePrefixProvider::RadixTree))
|
||||
.then(|| RadixTreePrefixProvider::new(engine_state.tree(), Arc::clone(&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();
|
||||
Ok(Arc::new(app_context))
|
||||
}
|
||||
@@ -350,7 +357,7 @@ async fn report_drain_progress(
|
||||
tracing::$level!(
|
||||
elapsed_secs = $elapsed,
|
||||
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 \
|
||||
SIGKILL when terminationGracePeriodSeconds expires",
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! Router-local load.
|
||||
//!
|
||||
//! 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
|
||||
//! affinity. It fails open on a missing sample — see [`queue_gate_admits`].
|
||||
//! 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
|
||||
//! 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::{CacheCandidate, CacheCandidateProposal, GuardHints, SelectionProposal};
|
||||
use crate::state::load_monitor::engine_reported_load::{
|
||||
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad, EngineReportedWorkerLoad,
|
||||
};
|
||||
use crate::workers::Worker;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
@@ -173,13 +175,13 @@ pub struct CacheCandidateResolution {
|
||||
/// The queue gate, in one place. Both subtle decisions live here: the
|
||||
/// boundary is `<` (a worker AT the limit is already making this request
|
||||
/// 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
|
||||
/// running request from a waiting one — so there is no honest substitute,
|
||||
/// and the gate fails open rather than comparing the limit against a
|
||||
/// different quantity.
|
||||
pub(crate) fn queue_gate_admits(
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
worker: &Worker,
|
||||
limit: Option<u64>,
|
||||
) -> 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
|
||||
/// false — an unknown queue is not a proven full one.
|
||||
pub(crate) fn fleet_is_all_queued(
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
fleet: &[Arc<Worker>],
|
||||
limit: Option<u64>,
|
||||
) -> bool {
|
||||
@@ -216,7 +218,7 @@ pub(crate) fn fleet_is_all_queued(
|
||||
pub fn resolve_cache_candidates(
|
||||
proposal: &CacheCandidateProposal,
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
fleet: &[Arc<Worker>],
|
||||
) -> CacheCandidateResolution {
|
||||
let queue_limit = proposal.worker_queue_limit;
|
||||
@@ -397,7 +399,7 @@ pub fn resolve_prefill(
|
||||
range: &CandidateRange<'_>,
|
||||
proposal: &SelectionProposal,
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
queue_limit: Option<u64>,
|
||||
min_load_choices: usize,
|
||||
) -> Option<FinalDecision> {
|
||||
@@ -431,7 +433,7 @@ pub fn resolve_prefill_admitted(
|
||||
range: &CandidateRange<'_>,
|
||||
proposal: &SelectionProposal,
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
queue_limit: Option<u64>,
|
||||
) -> Option<FinalDecision> {
|
||||
if !contains_worker(range, &proposal.primary) {
|
||||
@@ -492,7 +494,7 @@ pub fn resolve_decode(
|
||||
domain: &CandidateDomain,
|
||||
proposal: &SelectionProposal,
|
||||
request_kv_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
) -> Option<FinalDecision> {
|
||||
if domain.stage != RoutingStage::Decode || !contains_domain_worker(domain, &proposal.primary) {
|
||||
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.
|
||||
/// 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 {
|
||||
return true;
|
||||
};
|
||||
@@ -560,7 +562,7 @@ fn is_prefill_admitted(
|
||||
range: &CandidateRange<'_>,
|
||||
worker: &Arc<Worker>,
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
) -> bool {
|
||||
let load = snapshot.fresh_native_cache_load_for_url(&worker.url);
|
||||
has_kv_capacity(load, request_input_tokens)
|
||||
@@ -576,7 +578,7 @@ fn is_prefill_admitted(
|
||||
fn is_decode_admitted(
|
||||
worker: &Arc<Worker>,
|
||||
request_kv_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
) -> bool {
|
||||
has_kv_capacity(
|
||||
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
|
||||
/// candidate sets use Router-local active load to preserve ordering.
|
||||
pub(crate) struct FreshLoadLookup<'a> {
|
||||
by_worker_id: HashMap<String, &'a NativeCacheWorkerLoad>,
|
||||
basic_by_worker_id: HashMap<String, &'a EngineWorkerLoad>,
|
||||
by_worker_id: HashMap<String, &'a EngineReportedSchedulingLoad>,
|
||||
basic_by_worker_id: HashMap<String, &'a EngineReportedWorkerLoad>,
|
||||
local_active_by_worker_id: HashMap<String, usize>,
|
||||
compare_engine: bool,
|
||||
compare_basic_engine: bool,
|
||||
@@ -701,13 +703,13 @@ pub(crate) struct FreshLoadLookup<'a> {
|
||||
|
||||
impl<'a> FreshLoadLookup<'a> {
|
||||
pub(crate) fn new<'w>(
|
||||
snapshot: Option<&'a EngineLoadSnapshot>,
|
||||
snapshot: Option<&'a EngineReportedLoadSnapshot>,
|
||||
workers: impl IntoIterator<Item = &'w Arc<Worker>>,
|
||||
) -> Self {
|
||||
let workers: Vec<&Arc<Worker>> = workers.into_iter().collect();
|
||||
let local_active_by_worker_id: HashMap<String, usize> = workers
|
||||
.iter()
|
||||
.map(|worker| (worker.id.0.clone(), worker.active_load()))
|
||||
.map(|worker| (worker.id.0.clone(), worker.router_inflight_load()))
|
||||
.collect();
|
||||
let by_worker_id = snapshot
|
||||
.into_iter()
|
||||
@@ -745,14 +747,14 @@ impl<'a> FreshLoadLookup<'a> {
|
||||
pub(crate) fn get(
|
||||
&self,
|
||||
worker_id: &crate::discovery::WorkerId,
|
||||
) -> Option<&'a NativeCacheWorkerLoad> {
|
||||
) -> Option<&'a EngineReportedSchedulingLoad> {
|
||||
self.by_worker_id.get(worker_id.0.as_str()).copied()
|
||||
}
|
||||
|
||||
fn comparable_get(
|
||||
&self,
|
||||
worker_id: &crate::discovery::WorkerId,
|
||||
) -> Option<&'a NativeCacheWorkerLoad> {
|
||||
) -> Option<&'a EngineReportedSchedulingLoad> {
|
||||
self.compare_engine.then(|| self.get(worker_id)).flatten()
|
||||
}
|
||||
|
||||
@@ -853,7 +855,7 @@ impl<'a> FreshLoadLookup<'a> {
|
||||
}
|
||||
|
||||
struct PressureKey<'a> {
|
||||
load: Option<&'a NativeCacheWorkerLoad>,
|
||||
load: Option<&'a EngineReportedSchedulingLoad>,
|
||||
local_active: usize,
|
||||
}
|
||||
|
||||
@@ -861,7 +863,7 @@ fn range_fallback(
|
||||
range: &CandidateRange<'_>,
|
||||
legal: &[Arc<Worker>],
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
queue_limit: Option<u64>,
|
||||
) -> Option<(Arc<Worker>, DecisionReason)> {
|
||||
let admitted = legal
|
||||
@@ -922,7 +924,7 @@ fn legal_prefill_candidates(
|
||||
fn decode_domain_fallback(
|
||||
domain: &CandidateDomain,
|
||||
request_kv_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
) -> Option<(Arc<Worker>, DecisionReason)> {
|
||||
let admitted = domain
|
||||
.workers
|
||||
@@ -940,7 +942,7 @@ fn decode_domain_fallback(
|
||||
pub(crate) fn compare_prefill_pressure(
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
snapshot: Option<&EngineReportedLoadSnapshot>,
|
||||
) -> Ordering {
|
||||
match snapshot.and_then(|snapshot| {
|
||||
Some((
|
||||
@@ -948,13 +950,19 @@ pub(crate) fn compare_prefill_pressure(
|
||||
snapshot.fresh_native_cache_load_for_url(&right.url)?,
|
||||
))
|
||||
}) {
|
||||
Some((left_load, right_load)) => compare_prefill_load(left_load, right_load)
|
||||
.then_with(|| left.active_load().cmp(&right.active_load())),
|
||||
None => left.active_load().cmp(&right.active_load()),
|
||||
Some((left_load, right_load)) => {
|
||||
compare_prefill_load(left_load, right_load).then_with(|| {
|
||||
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_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 (
|
||||
left.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(
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
snapshot: Option<&EngineReportedLoadSnapshot>,
|
||||
) -> Ordering {
|
||||
match snapshot.and_then(|snapshot| {
|
||||
Some((
|
||||
@@ -986,13 +997,22 @@ pub(crate) fn compare_decode_pressure(
|
||||
snapshot.fresh_native_cache_load_for_url(&right.url)?,
|
||||
))
|
||||
}) {
|
||||
Some((left_load, right_load)) => compare_decode_load(left_load, right_load)
|
||||
.then_with(|| left.active_load().cmp(&right.active_load())),
|
||||
None => left.active_load().cmp(&right.active_load()),
|
||||
Some((left_load, right_load)) => {
|
||||
compare_decode_load(left_load, right_load).then_with(|| {
|
||||
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) {
|
||||
(left_cap, right_cap) if left_cap > 0 && right_cap > 0 => u128::from(left.num_used_tokens)
|
||||
.saturating_mul(u128::from(right_cap))
|
||||
@@ -1010,7 +1030,7 @@ fn pressure_guard_prefers_backup(
|
||||
primary: &Arc<Worker>,
|
||||
backup: &Arc<Worker>,
|
||||
hints: &GuardHints,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
) -> bool {
|
||||
if !hints.enable_pressure_guard {
|
||||
return false;
|
||||
@@ -1059,15 +1079,15 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
7,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, running, waiting, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: *running,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_waiting_uncached_tokens: *waiting,
|
||||
@@ -1693,7 +1713,7 @@ mod tests {
|
||||
];
|
||||
let proposal = SelectionProposal::primary(Arc::clone(&primary));
|
||||
let load = |waiting: u64, waiting_uncached: u64, total: u64, max_total: u64| {
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: waiting,
|
||||
num_waiting_uncached_tokens: waiting_uncached,
|
||||
@@ -1710,7 +1730,7 @@ mod tests {
|
||||
// waits 5 (over the limit) behind 1 uncached token, busy_unqueued
|
||||
// waits 3 (under the limit) behind 1000 uncached tokens. The primary
|
||||
// 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,
|
||||
[
|
||||
(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,
|
||||
RoutingStage,
|
||||
};
|
||||
use crate::policies::engine_load::EngineLoadSnapshot;
|
||||
use crate::policies::registry::select_decode_with_affinity;
|
||||
use crate::policies::{ProposalKind, SelectionProposal};
|
||||
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
|
||||
use rand::Rng;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DecodeSelectionContext<'a> {
|
||||
load_snapshot: Option<&'a EngineLoadSnapshot>,
|
||||
load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
|
||||
prefill_url: Option<&'a str>,
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ impl<'a> DecodeSelectionContext<'a> {
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
pub fn load_snapshot(&self) -> Option<&EngineLoadSnapshot> {
|
||||
pub fn load_snapshot(&self) -> Option<&EngineReportedLoadSnapshot> {
|
||||
self.load_snapshot
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn resolve_decode_with_capacity_fallback(
|
||||
domain: &CandidateDomain,
|
||||
proposal: &SelectionProposal,
|
||||
request_kv_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
snapshot: &EngineReportedLoadSnapshot,
|
||||
) -> Option<FinalDecision> {
|
||||
if let Some(decision) = resolve_decode(domain, proposal, request_kv_tokens, snapshot) {
|
||||
return Some(decision);
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::config::{
|
||||
use crate::discovery::ModelId;
|
||||
use crate::policies::{
|
||||
cache_aware::CacheAwarePolicy,
|
||||
kv_events::{BlockSizeOracle, HashTree},
|
||||
load_based::LoadBasedPolicy,
|
||||
power_of_two::PowerOfTwoChoicesPolicy,
|
||||
random::RandomPolicy,
|
||||
@@ -20,6 +19,7 @@ use crate::policies::{
|
||||
sticky::StickyPolicy,
|
||||
Policy, PolicyRegistry,
|
||||
};
|
||||
use crate::state::kv_events::{BlockSizeOracle, HashTree};
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -237,7 +237,7 @@ pub fn build_registry_with_defaults(cfg: &Config) -> Result<PolicyRegistry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ProxyConfig, ServerConfig,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ProxyConfig, ServerConfig,
|
||||
StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
|
||||
@@ -353,7 +353,7 @@ mod tests {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,11 @@ impl ScoringPolicy for LoadBasedPolicy {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use crate::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad};
|
||||
use crate::policies::scoring::argmax::TIE_EPSILON;
|
||||
use crate::policies::Policy;
|
||||
use crate::state::load_monitor::engine_reported_load::{
|
||||
EngineReportedLoadSnapshot, EngineReportedWorkerLoad,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -94,7 +96,10 @@ mod tests {
|
||||
let ok = (scores[i] > scores[j] + TIE_EPSILON, scores[i].is_nan());
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -107,12 +112,12 @@ mod tests {
|
||||
// After the request snapshot, local counters say w0 is lighter.
|
||||
// The policy must still preserve the frozen Engine Load ordering.
|
||||
let _after_snapshot: Vec<_> = (0..10).map(|_| w1.load_guard()).collect();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
let snapshot = EngineReportedLoadSnapshot::from_workers(
|
||||
23,
|
||||
HashMap::from([
|
||||
(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 50,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -122,7 +127,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
w1.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -148,12 +153,12 @@ mod tests {
|
||||
let w0 = worker("w0");
|
||||
let w1 = worker("w1");
|
||||
let captured_at = Instant::now();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
let snapshot = EngineReportedLoadSnapshot::from_workers(
|
||||
37,
|
||||
HashMap::from([
|
||||
(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -163,7 +168,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
w1.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -192,12 +197,12 @@ mod tests {
|
||||
let _before_snapshot = [w0.timestamped_load_guard(), w0.timestamped_load_guard()];
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let captured_at = Instant::now();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
let snapshot = EngineReportedLoadSnapshot::from_workers(
|
||||
41,
|
||||
HashMap::from([
|
||||
(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -207,7 +212,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
w1.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -233,11 +238,11 @@ mod tests {
|
||||
let w0 = worker("w0");
|
||||
let w1 = worker("w1");
|
||||
let _local_load = [w0.load_guard(), w0.load_guard()];
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
let snapshot = EngineReportedLoadSnapshot::from_workers(
|
||||
43,
|
||||
HashMap::from([(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub mod active_load;
|
||||
pub mod admission;
|
||||
pub mod buckets;
|
||||
pub mod cache_aware;
|
||||
pub mod decode;
|
||||
pub mod engine_load;
|
||||
pub mod factory;
|
||||
pub mod kv_events;
|
||||
pub mod load_based;
|
||||
pub mod power_of_two;
|
||||
pub mod prefix_provider;
|
||||
@@ -22,9 +19,9 @@ pub mod sticky;
|
||||
|
||||
use crate::discovery::ModelId;
|
||||
use crate::policies::buckets::{BucketRequest, BucketSelector};
|
||||
use crate::policies::engine_load::EngineLoadSnapshot;
|
||||
use crate::policies::scoring::{EligibilityFilter, ScoringPolicy};
|
||||
use crate::server::metrics::MetricsRegistry;
|
||||
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
|
||||
use crate::tokenizer::{adapter, TokenizerRegistry};
|
||||
use crate::workers::Worker;
|
||||
use dashmap::DashMap;
|
||||
@@ -176,7 +173,7 @@ pub struct SelectionContext<'a> {
|
||||
input_tokens: Option<u64>,
|
||||
request_tokens: Option<&'a [u32]>,
|
||||
external_prefix: Option<&'a ExternalPrefixSignal>,
|
||||
load_snapshot: Option<&'a EngineLoadSnapshot>,
|
||||
load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
|
||||
prefill_cache_bucket: Option<(&'a BucketSelector, BucketRequest)>,
|
||||
affinity_lookup_enabled: bool,
|
||||
affinity_assignment_enabled: bool,
|
||||
@@ -254,7 +251,7 @@ impl<'a> SelectionContext<'a> {
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -315,7 +312,7 @@ impl<'a> SelectionContext<'a> {
|
||||
self.external_prefix
|
||||
}
|
||||
|
||||
pub fn load_snapshot(&self) -> Option<&EngineLoadSnapshot> {
|
||||
pub fn load_snapshot(&self) -> Option<&EngineReportedLoadSnapshot> {
|
||||
self.load_snapshot
|
||||
}
|
||||
|
||||
@@ -592,10 +589,12 @@ mod tests {
|
||||
resolve_cache_candidates, resolve_prefill, CandidateRange, DecisionReason, FreshLoadLookup,
|
||||
};
|
||||
use crate::policies::cache_aware::CacheAwarePolicy;
|
||||
use crate::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
|
||||
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
|
||||
use crate::policies::round_robin::RoundRobinPolicy;
|
||||
use crate::policies::session_aware::SessionAwarePolicy;
|
||||
use crate::state::load_monitor::engine_reported_load::{
|
||||
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -1222,15 +1221,15 @@ mod tests {
|
||||
assert!(proposal.backup.is_some());
|
||||
}
|
||||
|
||||
fn snapshot(entries: &[(&Arc<Worker>, TestEngineLoad)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn snapshot(entries: &[(&Arc<Worker>, TestEngineLoad)]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
1,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, aggregate)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: aggregate.num_running_reqs,
|
||||
num_waiting_reqs: aggregate.num_waiting_reqs,
|
||||
num_waiting_uncached_tokens: aggregate
|
||||
@@ -1616,7 +1615,7 @@ mod tests {
|
||||
fn missing_engine_snapshot_does_not_hard_reject_a_registry_healthy_primary() {
|
||||
let primary = worker("primary");
|
||||
let workers = vec![Arc::clone(&primary)];
|
||||
let snapshot = EngineLoadSnapshot::default();
|
||||
let snapshot = EngineReportedLoadSnapshot::default();
|
||||
|
||||
let decision = resolve_prefill(
|
||||
&CandidateRange::global(&workers),
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
use crate::config::DEFAULT_MIN_LOAD_CHOICES;
|
||||
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::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
|
||||
use crate::workers::Worker;
|
||||
use rand::seq::index::sample;
|
||||
use rand::Rng;
|
||||
@@ -81,7 +81,7 @@ impl Policy for PowerOfTwoChoicesPolicy {
|
||||
|
||||
pub(crate) fn select_k_with_snapshot(
|
||||
workers: &[Arc<Worker>],
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
snapshot: Option<&EngineReportedLoadSnapshot>,
|
||||
choices: usize,
|
||||
queue_limit: Option<u64>,
|
||||
) -> Option<Arc<Worker>> {
|
||||
@@ -95,7 +95,7 @@ pub(crate) fn select_k_with_snapshot(
|
||||
/// a queueing worker while an unqueued one exists.
|
||||
fn sample_pool<'w>(
|
||||
workers: &'w [Arc<Worker>],
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
snapshot: Option<&EngineReportedLoadSnapshot>,
|
||||
queue_limit: Option<u64>,
|
||||
) -> Cow<'w, [Arc<Worker>]> {
|
||||
// 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.
|
||||
fn best_two_of_sample(
|
||||
pool: &[Arc<Worker>],
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
snapshot: Option<&EngineReportedLoadSnapshot>,
|
||||
choices: usize,
|
||||
) -> Option<(Arc<Worker>, Option<Arc<Worker>>)> {
|
||||
let len = pool.len();
|
||||
@@ -186,7 +186,7 @@ fn best_two_of_sample(
|
||||
mod tests {
|
||||
use super::*;
|
||||
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;
|
||||
|
||||
fn worker(id: &str) -> Arc<Worker> {
|
||||
@@ -202,15 +202,15 @@ mod tests {
|
||||
/// Snapshot keyed on waiting depth; `waiting` sets both the queue-gate
|
||||
/// reading (`num_waiting_reqs`) and the pressure ordering
|
||||
/// (`num_waiting_uncached_tokens`), so one knob drives both.
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64)]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
7,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, waiting)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_waiting_uncached_tokens: *waiting,
|
||||
@@ -234,8 +234,8 @@ mod tests {
|
||||
/// `compare_prefill_pressure` intransitive: a slow worker with a shallow
|
||||
/// 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.
|
||||
fn mixed_estimate_snapshot(workers: &[Arc<Worker>]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn mixed_estimate_snapshot(workers: &[Arc<Worker>]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
11,
|
||||
workers
|
||||
.iter()
|
||||
@@ -244,7 +244,7 @@ mod tests {
|
||||
let waiting = (index as u64 * 7) % 13;
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_waiting_uncached_tokens: waiting,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::ExternalPrefixSignal;
|
||||
use crate::policies::kv_events::{
|
||||
use crate::state::kv_events::{
|
||||
compute_block_hashes, compute_block_hashes_bigram, BlockSizeOracle, HashTree,
|
||||
};
|
||||
use sgl_kv_indexer::{PrefixMatch, PrefixOutcome};
|
||||
|
||||
@@ -211,7 +211,7 @@ impl PdPoolResolver {
|
||||
///
|
||||
/// 1. **Same-host preference.** Parse the host portion of both URLs
|
||||
/// (`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.
|
||||
/// 2. **Fallback: min-load among closed-breaker candidates.** No
|
||||
/// 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() {
|
||||
0
|
||||
} 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();
|
||||
let median = loads[loads.len() / 2];
|
||||
((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() {
|
||||
let affinity_peer = healthy.iter().find(|w| {
|
||||
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 {
|
||||
return Some(Arc::clone(w));
|
||||
@@ -275,14 +275,17 @@ pub fn select_decode_with_affinity(
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Rule 3: last-resort min-load over all candidates (every
|
||||
// breaker is open). The caller's dispatch will likely fail and
|
||||
// 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
|
||||
|
||||
@@ -23,7 +23,7 @@ impl Overloaded {
|
||||
impl EligibilityFilter for Overloaded {
|
||||
fn keep(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Vec<bool> {
|
||||
(workers.iter())
|
||||
.map(|w| w.active_load() < self.max_in_flight)
|
||||
.map(|w| w.router_inflight_load() < self.max_in_flight)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ impl Policy for Overloaded {
|
||||
.collect();
|
||||
eligible
|
||||
.iter()
|
||||
.min_by_key(|w| w.active_load())
|
||||
.min_by_key(|w| w.router_inflight_load())
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,13 @@ impl Selector for Argmax {
|
||||
);
|
||||
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
|
||||
.into_iter()
|
||||
.filter(|&i| workers[i].active_load() == min_load)
|
||||
.filter(|&i| workers[i].router_inflight_load() == min_load)
|
||||
.collect();
|
||||
let k = self.rotor.fetch_add(1, Ordering::Relaxed) % tied.len();
|
||||
Some(tied[k])
|
||||
|
||||
@@ -417,11 +417,13 @@ mod tests {
|
||||
use crate::config::AffinityConfig;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use crate::policies::admission::{resolve_prefill, CandidateRange};
|
||||
use crate::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
|
||||
use crate::policies::load_based::LoadBasedPolicy;
|
||||
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
|
||||
use crate::policies::round_robin::RoundRobinPolicy;
|
||||
use crate::policies::session_aware::SessionAwarePolicy;
|
||||
use crate::state::load_monitor::engine_reported_load::{
|
||||
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -439,15 +441,15 @@ mod tests {
|
||||
vec![worker("a"), worker("b"), worker("c")]
|
||||
}
|
||||
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
1,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, running, waiting, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: *running,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_waiting_uncached_tokens: *waiting,
|
||||
@@ -789,7 +791,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(proposal.primary.id, ws[2].id);
|
||||
|
||||
let snapshot = EngineLoadSnapshot::default();
|
||||
let snapshot = EngineReportedLoadSnapshot::default();
|
||||
let decision = resolve_prefill(
|
||||
&CandidateRange::global(&ws),
|
||||
&proposal,
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
//! Prefix-cache scores from the KV-event [`HashTree`].
|
||||
|
||||
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,
|
||||
};
|
||||
use crate::policies::SelectionContext;
|
||||
use crate::workers::Worker;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -122,7 +122,7 @@ impl EligibilityFilter for PrefixCachePolicy {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use crate::policies::kv_events::KvWorkerId;
|
||||
use crate::state::kv_events::KvWorkerId;
|
||||
|
||||
const BLOCK: usize = 4;
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ use crate::policies::buckets::{BucketRequest, BucketSelector};
|
||||
use crate::policies::decode::{
|
||||
build_decode_policy, resolve_decode_with_capacity_fallback, DecodeSelectionContext,
|
||||
};
|
||||
use crate::policies::engine_load::EngineLoadSnapshot;
|
||||
use crate::policies::{
|
||||
ExternalPrefixSignal, Policy, PrefillProposal, ProposalKind, SelectionContext,
|
||||
};
|
||||
use crate::server::metrics::{CacheAwareDecision, MetricsRegistry, PolicySelectionFailureReason};
|
||||
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadSnapshot;
|
||||
use crate::workers::Worker;
|
||||
|
||||
/// 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`
|
||||
/// defaults to `uses_shared_prefill_admission`, which is what keeps the
|
||||
/// 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 ttft_slo_ms: Option<u64>,
|
||||
pub tps_slo: Option<f64>,
|
||||
@@ -607,7 +607,7 @@ pub(crate) struct DecodeSelectionInputs<'a> {
|
||||
/// Required: every rung resolves its proposal against the snapshot, so
|
||||
/// without one the ladder reports no peer at all rather than picking one
|
||||
/// blind.
|
||||
pub load_snapshot: Option<&'a EngineLoadSnapshot>,
|
||||
pub load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
|
||||
}
|
||||
|
||||
/// Runs the decode selection ladder.
|
||||
@@ -699,12 +699,14 @@ mod tests {
|
||||
use crate::policies::admission::{resolve_prefill_admitted, CandidateRange, DecisionReason};
|
||||
use crate::policies::buckets::BucketSelector;
|
||||
use crate::policies::cache_aware::CacheAwarePolicy;
|
||||
use crate::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
|
||||
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
|
||||
use crate::policies::{ExternalPrefixSignal, Policy, ProposalKind, SelectionProposal};
|
||||
use crate::server::metrics::{
|
||||
CacheAwareDecision, MetricsRegistry, PolicySelectionFailureReason,
|
||||
};
|
||||
use crate::state::load_monitor::engine_reported_load::{
|
||||
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
|
||||
};
|
||||
use crate::workers::Worker;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -720,15 +722,15 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `(worker, tokens already held, published KV capacity)`.
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64)]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
7,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_waiting_uncached_tokens: 0,
|
||||
@@ -749,15 +751,15 @@ mod tests {
|
||||
/// `(worker, waiting requests, tokens already held, published KV
|
||||
/// capacity)`. The queue gate reads `num_waiting_reqs`, which the plain
|
||||
/// [`snapshot`] fixture pins at zero.
|
||||
fn queued_snapshot(entries: &[(&Arc<Worker>, u64, u64, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn queued_snapshot(entries: &[(&Arc<Worker>, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
7,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, waiting, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_waiting_uncached_tokens: *waiting,
|
||||
@@ -811,7 +813,7 @@ mod tests {
|
||||
metrics: &'a MetricsRegistry,
|
||||
model_id: &'a ModelId,
|
||||
workers: &'a [Arc<Worker>],
|
||||
load_snapshot: Option<&'a EngineLoadSnapshot>,
|
||||
load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
|
||||
request_input_tokens: u64,
|
||||
) -> PrefillSelectionInputs<'a> {
|
||||
PrefillSelectionInputs {
|
||||
@@ -841,7 +843,7 @@ mod tests {
|
||||
bucket_selector: &'a BucketSelector,
|
||||
model_id: &'a ModelId,
|
||||
decode_workers: &'a [Arc<Worker>],
|
||||
load_snapshot: Option<&'a EngineLoadSnapshot>,
|
||||
load_snapshot: Option<&'a EngineReportedLoadSnapshot>,
|
||||
request_input_tokens: u64,
|
||||
) -> DecodeSelectionInputs<'a> {
|
||||
DecodeSelectionInputs {
|
||||
|
||||
@@ -5,87 +5,43 @@
|
||||
|
||||
use crate::config::{AffinityConfig, SessionAffinityMode};
|
||||
use crate::discovery::WorkerId;
|
||||
use crate::policies::active_load::{spawn_sweeper, Clock, JanitorHandle, SystemTimeClock};
|
||||
use crate::policies::admission::compare_prefill_pressure;
|
||||
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
|
||||
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 dashmap::DashMap;
|
||||
use rand::Rng;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct SessionAwarePolicy {
|
||||
state: Arc<SessionState>,
|
||||
store: Arc<AffinityStore>,
|
||||
config: AffinityConfig,
|
||||
_janitor: Option<JanitorHandle>,
|
||||
}
|
||||
|
||||
impl SessionAwarePolicy {
|
||||
pub fn new(config: AffinityConfig) -> Self {
|
||||
let state = Arc::new(SessionState {
|
||||
assignments: DashMap::new(),
|
||||
clock: Arc::new(SystemTimeClock),
|
||||
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
|
||||
};
|
||||
let store = AffinityStore::new(Duration::from_secs(config.session_idle_secs));
|
||||
let _janitor =
|
||||
store.spawn_sweeper(Duration::from_secs(config.session_eviction_interval_secs));
|
||||
Self {
|
||||
state,
|
||||
store,
|
||||
config,
|
||||
_janitor,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
state: Arc::new(SessionState {
|
||||
assignments: DashMap::new(),
|
||||
clock,
|
||||
idle: Duration::from_secs(config.session_idle_secs),
|
||||
}),
|
||||
store: AffinityStore::with_clock(Duration::from_secs(config.session_idle_secs), clock),
|
||||
config,
|
||||
_janitor: None,
|
||||
}
|
||||
@@ -93,12 +49,12 @@ impl SessionAwarePolicy {
|
||||
|
||||
#[cfg(test)]
|
||||
fn sweep_expired(&self) -> usize {
|
||||
self.state.sweep_expired()
|
||||
self.store.sweep_expired()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn assignment_count(&self) -> usize {
|
||||
self.state.assignments.len()
|
||||
self.store.len()
|
||||
}
|
||||
|
||||
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 assigned = self
|
||||
.state
|
||||
.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));
|
||||
}
|
||||
if let Some(primary) = self.store.bound(&assignment_key, workers) {
|
||||
return Some(self.affinity_proposal(Arc::clone(primary), workers, ctx, session_id));
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return;
|
||||
};
|
||||
self.state.assignments.insert(
|
||||
// Overwrites any previous binding for the key.
|
||||
self.store.bind(
|
||||
self.assignment_key(session_id, ctx),
|
||||
Assignment {
|
||||
worker_id: selected.id.clone(),
|
||||
last_seen: self.state.clock.now(),
|
||||
},
|
||||
selected,
|
||||
std::slice::from_ref(selected),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,7 +170,7 @@ impl std::fmt::Debug for SessionAwarePolicy {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SessionAwarePolicy")
|
||||
.field("config", &self.config)
|
||||
.field("assignments", &self.state.assignments.len())
|
||||
.field("assignments", &self.store.len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
@@ -308,7 +253,7 @@ fn stable_backup(
|
||||
mod lifecycle_tests {
|
||||
use super::*;
|
||||
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::time::{Duration, Instant};
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
//! - **New key, or pinned worker unhealthy** → pick a worker via `fallback`
|
||||
//! and record the assignment.
|
||||
//!
|
||||
//! Worker identity is the worker URL (stable across discovery events).
|
||||
//! Worker identity is the worker ID supplied by discovery.
|
||||
//!
|
||||
//! # Eviction
|
||||
//! 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.
|
||||
//! The sweeper is spawned only when constructed inside a Tokio runtime;
|
||||
//! unit tests use [`StickyPolicy::with_clock`] and drive eviction
|
||||
@@ -34,49 +34,60 @@
|
||||
//! is intentionally out of scope here.
|
||||
|
||||
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::server::metrics::{MetricsRegistry, StickyOutcome};
|
||||
use crate::state::load_monitor::router_inflight_load::JanitorHandle;
|
||||
use crate::state::AffinityStore;
|
||||
use crate::workers::Worker;
|
||||
|
||||
/// One routing-key → worker pin, with the last time it was referenced (used
|
||||
/// by the idle-eviction sweep).
|
||||
#[derive(Debug)]
|
||||
struct Assignment {
|
||||
worker_url: String,
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Sticky-session policy. See the module docs for behavior and limitations.
|
||||
pub struct StickyPolicy {
|
||||
store: Arc<AffinityStore>,
|
||||
/// Selector for keyless requests and for the initial pin of a new key.
|
||||
fallback: Arc<dyn Policy>,
|
||||
/// Set once via `Policy::attach_metrics`; recording is a no-op until then.
|
||||
metrics: OnceLock<Arc<MetricsRegistry>>,
|
||||
/// Background idle-eviction sweeper; `None` outside a Tokio runtime.
|
||||
_janitor: Option<JanitorHandle>,
|
||||
}
|
||||
|
||||
impl StickyState {
|
||||
/// Remove every assignment idle longer than `idle`. Returns the count
|
||||
/// removed. Called on a fixed cadence by the background sweeper.
|
||||
impl StickyPolicy {
|
||||
pub fn new(idle: Duration, eviction_interval: Duration, fallback: Arc<dyn Policy>) -> Self {
|
||||
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 {
|
||||
let now = self.clock.now();
|
||||
let mut removed = 0;
|
||||
self.assignments.retain(|_key, a| {
|
||||
let keep = now.saturating_duration_since(a.last_seen) <= self.idle;
|
||||
if !keep {
|
||||
removed += 1;
|
||||
}
|
||||
keep
|
||||
});
|
||||
removed
|
||||
self.store.sweep_expired()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn assignment_count(&self) -> usize {
|
||||
self.store.len()
|
||||
}
|
||||
|
||||
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 {
|
||||
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
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);
|
||||
};
|
||||
|
||||
// Fast path: an existing pin whose worker is still in the healthy set.
|
||||
let mut existing = false;
|
||||
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);
|
||||
if let Some(worker) = self.store.bound(key, workers) {
|
||||
self.record(StickyOutcome::Hit);
|
||||
return Some(Arc::clone(worker));
|
||||
}
|
||||
|
||||
// Vacant key, or the pinned worker dropped out: (re)assign via the
|
||||
// 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.
|
||||
// Vacant key, or the pinned worker dropped out: (re)pin the fallback's choice.
|
||||
let remap = self.store.contains(key);
|
||||
let chosen = self.fallback.select(workers, ctx)?;
|
||||
self.state.assignments.insert(
|
||||
key.to_string(),
|
||||
Assignment {
|
||||
worker_url: chosen.url.clone(),
|
||||
last_seen: self.state.clock.now(),
|
||||
},
|
||||
);
|
||||
self.state.record(if existing {
|
||||
let chosen = Arc::clone(self.store.bind(key.to_string(), &chosen, workers));
|
||||
self.record(if remap {
|
||||
StickyOutcome::Remap
|
||||
} else {
|
||||
StickyOutcome::Assigned
|
||||
@@ -210,7 +120,7 @@ impl Policy for StickyPolicy {
|
||||
}
|
||||
|
||||
fn attach_metrics(&self, metrics: Arc<MetricsRegistry>) {
|
||||
let _ = self.state.metrics.set(metrics);
|
||||
let _ = self.metrics.set(metrics);
|
||||
}
|
||||
|
||||
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 {
|
||||
f.debug_struct("StickyPolicy")
|
||||
.field("fallback", &self.fallback)
|
||||
.field("idle", &self.state.idle)
|
||||
.field("assignments", &self.state.assignments.len())
|
||||
.field("assignments", &self.store.len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
@@ -236,6 +145,7 @@ impl std::fmt::Debug for StickyPolicy {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn sticky_propagates_fallback_load_snapshot_capability() {
|
||||
@@ -271,7 +181,9 @@ mod tests {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -374,7 +286,9 @@ mod tests {
|
||||
#[test]
|
||||
fn sweep_evicts_idle_entries_keeps_fresh() {
|
||||
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 workers = vec![worker("w0"), worker("w1")];
|
||||
|
||||
@@ -401,7 +315,9 @@ mod tests {
|
||||
#[test]
|
||||
fn hit_refreshes_last_seen_so_active_key_is_not_evicted() {
|
||||
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 workers = vec![worker("w0")];
|
||||
let ctx = SelectionContext::with_routing_key(&model, None, Some("u1"));
|
||||
|
||||
@@ -262,7 +262,7 @@ impl Proxy {
|
||||
/// pump task and held for the entire body lifetime (headers → last byte
|
||||
/// / client disconnect). The proxy does not inspect the boxed value; it
|
||||
/// 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
|
||||
/// for the full streaming lifetime — without which a long-running SSE
|
||||
/// response would under-report load.
|
||||
|
||||
@@ -88,7 +88,7 @@ impl ErrorEventScanner {
|
||||
/// pump finishes (stream exhausted, client disconnects, or upstream errors).
|
||||
/// The opaque `Box<dyn Send + 'static>` accepts any drop-only payload — most
|
||||
/// 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
|
||||
/// 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
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
use crate::policies::active_load::ActiveLoadRegistry;
|
||||
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::PolicyRegistry;
|
||||
use crate::proxy::Proxy;
|
||||
use crate::server::inflight::InflightHttp;
|
||||
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::workers::WorkerRegistry;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
@@ -36,24 +36,24 @@ pub struct AppContext {
|
||||
pub bucket_selector: Arc<BucketSelector>,
|
||||
/// Per-worker active-load bookkeeping shared by the proxy, policies,
|
||||
/// timeout janitor, and metrics.
|
||||
pub active_load: Arc<ActiveLoadRegistry>,
|
||||
pub router_inflight_load: Arc<RouterInflightLoadRegistry>,
|
||||
/// Lightweight Prometheus-format metrics registry served via
|
||||
/// `/metrics`. Shared with the edge middleware (requests_total /
|
||||
/// responses_total), the chat handler (worker_requests_total), the
|
||||
/// active-load registry, policy-specific counters, and PD dispatch.
|
||||
pub metrics: Arc<MetricsRegistry>,
|
||||
/// 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 radix_tree_prefix_provider: Option<RadixTreePrefixProvider>,
|
||||
pub block_size_oracle: Arc<BlockSizeOracle>,
|
||||
/// Read-only handles `/metrics` pulls the KV storage-tier series from on
|
||||
/// scrape. `None` when this router maintains no local tree (external
|
||||
/// 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>,
|
||||
/// 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.
|
||||
pub inflight_http: Arc<InflightHttp>,
|
||||
readiness: AtomicU8,
|
||||
@@ -67,34 +67,34 @@ impl AppContext {
|
||||
registry: Arc<WorkerRegistry>,
|
||||
policies: Arc<PolicyRegistry>,
|
||||
) -> Self {
|
||||
Self::with_active_load(
|
||||
Self::with_router_inflight_load(
|
||||
config,
|
||||
tokenizers,
|
||||
proxy,
|
||||
registry,
|
||||
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)
|
||||
/// via [`Self::new`]; tests that exercise the janitor pass a registry
|
||||
/// built with a `MockClock`.
|
||||
pub fn with_active_load(
|
||||
pub fn with_router_inflight_load(
|
||||
config: Config,
|
||||
tokenizers: Arc<TokenizerRegistry>,
|
||||
proxy: Arc<Proxy>,
|
||||
registry: Arc<WorkerRegistry>,
|
||||
policies: Arc<PolicyRegistry>,
|
||||
active_load: Arc<ActiveLoadRegistry>,
|
||||
router_inflight_load: Arc<RouterInflightLoadRegistry>,
|
||||
) -> Self {
|
||||
let metrics = MetricsRegistry::new();
|
||||
// Wire the per-worker active-load gauge so `sgl_router_active_load`
|
||||
// mirrors the live counter on every register / drop / sweep.
|
||||
// Without this, the metric is permanently 0 in production even
|
||||
// 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
|
||||
// it here for policies that emit their own counters.
|
||||
policies.attach_metrics(Arc::clone(&metrics));
|
||||
@@ -106,13 +106,13 @@ impl AppContext {
|
||||
registry,
|
||||
policies,
|
||||
bucket_selector,
|
||||
active_load,
|
||||
router_inflight_load,
|
||||
metrics,
|
||||
prefix_index: None,
|
||||
radix_tree_prefix_provider: None,
|
||||
block_size_oracle: BlockSizeOracle::new(),
|
||||
kv_metrics: None,
|
||||
engine_load: EngineLoadTable::new(),
|
||||
engine_reported_load: EngineReportedLoadTable::new(),
|
||||
inflight_http: InflightHttp::new(),
|
||||
readiness: AtomicU8::new(READINESS_NOT_READY),
|
||||
}
|
||||
@@ -188,20 +188,20 @@ impl AppContext {
|
||||
},
|
||||
),
|
||||
proxy: crate::config::ProxyConfig::default(),
|
||||
active_load: crate::config::ActiveLoadConfig::default(),
|
||||
router_inflight_load: crate::config::InflightLoadConfig::default(),
|
||||
},
|
||||
tokenizers: Arc::new(TokenizerRegistry::default()),
|
||||
proxy: Arc::new(Proxy::new(std::time::Duration::from_secs(60)).expect("stub proxy")),
|
||||
registry: Arc::new(WorkerRegistry::default()),
|
||||
policies: Arc::new(PolicyRegistry::default()),
|
||||
bucket_selector: Arc::new(BucketSelector::new(None)),
|
||||
active_load: ActiveLoadRegistry::with_defaults(),
|
||||
router_inflight_load: RouterInflightLoadRegistry::with_defaults(),
|
||||
metrics: MetricsRegistry::new(),
|
||||
prefix_index: None,
|
||||
radix_tree_prefix_provider: None,
|
||||
block_size_oracle: BlockSizeOracle::new(),
|
||||
kv_metrics: None,
|
||||
engine_load: EngineLoadTable::new(),
|
||||
engine_reported_load: EngineReportedLoadTable::new(),
|
||||
inflight_http: InflightHttp::new(),
|
||||
readiness: AtomicU8::new(READINESS_NOT_READY),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
//! 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
|
||||
//! shutdown waits on something different and larger: every HTTP exchange still
|
||||
//! open on an accepted connection, on any route, until its response body has
|
||||
|
||||
@@ -375,12 +375,12 @@ impl PolicySelectionFailureReason {
|
||||
|
||||
/// Active-load kind label — separates the two axes of per-worker load.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ActiveLoadKind {
|
||||
pub enum RouterInflightLoadKind {
|
||||
PrefillTokens,
|
||||
DecodeBlocks,
|
||||
}
|
||||
|
||||
impl ActiveLoadKind {
|
||||
impl RouterInflightLoadKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PrefillTokens => "prefill_tokens",
|
||||
@@ -408,7 +408,7 @@ pub struct MetricsRegistry {
|
||||
request_duration: Mutex<HashMap<String, Histogram>>,
|
||||
ttft_seconds: Mutex<HashMap<String, Histogram>>,
|
||||
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>>>,
|
||||
decode_affinity_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,
|
||||
/// Circuit breaker state code: 0=closed, 1=open, 2=half_open.
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
|
||||
struct ActiveLoadKey {
|
||||
struct RouterInflightLoadKey {
|
||||
worker_url: String,
|
||||
kind: &'static str,
|
||||
}
|
||||
@@ -657,12 +657,17 @@ impl MetricsRegistry {
|
||||
|
||||
/// Set `sgl_router_active_load` for the given worker + kind. Replaces the
|
||||
/// previous value (gauge semantics).
|
||||
pub fn set_active_load(&self, worker_url: &str, kind: ActiveLoadKind, value: i64) {
|
||||
let key = ActiveLoadKey {
|
||||
pub fn set_router_inflight_load(
|
||||
&self,
|
||||
worker_url: &str,
|
||||
kind: RouterInflightLoadKind,
|
||||
value: i64,
|
||||
) {
|
||||
let key = RouterInflightLoadKey {
|
||||
worker_url: worker_url.to_owned(),
|
||||
kind: kind.as_str(),
|
||||
};
|
||||
let mut guard = self.active_load.lock();
|
||||
let mut guard = self.router_inflight_load.lock();
|
||||
let gauge = guard
|
||||
.entry(key)
|
||||
.or_insert_with(|| Arc::new(AtomicI64::new(0)))
|
||||
@@ -984,13 +989,13 @@ impl MetricsRegistry {
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
// active_load gauge
|
||||
// router_inflight_load gauge
|
||||
out.push_str(
|
||||
"# 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");
|
||||
let guard = self.active_load.lock();
|
||||
let mut entries: Vec<(&ActiveLoadKey, i64)> = guard
|
||||
let guard = self.router_inflight_load.lock();
|
||||
let mut entries: Vec<(&RouterInflightLoadKey, i64)> = guard
|
||||
.iter()
|
||||
.map(|(k, v)| (k, v.load(Ordering::Relaxed)))
|
||||
.collect();
|
||||
@@ -1651,8 +1656,8 @@ mod tests {
|
||||
#[test]
|
||||
fn set_active_load_gauge_overwrites() {
|
||||
let reg = MetricsRegistry::new();
|
||||
reg.set_active_load("http://w:30000", ActiveLoadKind::PrefillTokens, 100);
|
||||
reg.set_active_load("http://w:30000", ActiveLoadKind::PrefillTokens, 250);
|
||||
reg.set_router_inflight_load("http://w:30000", RouterInflightLoadKind::PrefillTokens, 100);
|
||||
reg.set_router_inflight_load("http://w:30000", RouterInflightLoadKind::PrefillTokens, 250);
|
||||
let out = reg.render();
|
||||
assert!(out.contains(
|
||||
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::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::selection::{
|
||||
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::error::ApiError;
|
||||
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 axum::body::Body;
|
||||
use axum::extract::State;
|
||||
@@ -113,17 +113,17 @@ fn capture_load_snapshot(
|
||||
ctx: &AppContext,
|
||||
policy: &dyn Policy,
|
||||
candidates: &[Arc<Worker>],
|
||||
) -> Option<EngineLoadSnapshot> {
|
||||
) -> Option<EngineReportedLoadSnapshot> {
|
||||
let needed = policy.needs_load_snapshot()
|
||||
|| candidates
|
||||
.iter()
|
||||
.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> {
|
||||
prefix_matches: Option<ExternalPrefixSignal>,
|
||||
load_snapshot: Option<EngineLoadSnapshot>,
|
||||
load_snapshot: Option<EngineReportedLoadSnapshot>,
|
||||
ttft_slo_ms: Option<u64>,
|
||||
tps_slo: Option<f64>,
|
||||
routing_key: Option<&'a str>,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
use super::preparation::{generate_room_id, BootstrapFields, PreparedChatRequest};
|
||||
use crate::discovery::WorkerMode;
|
||||
use crate::policies::active_load::ActiveLoadGuard;
|
||||
use crate::proxy::sse::StreamEnd;
|
||||
use crate::server::app_context::AppContext;
|
||||
use crate::server::error::ApiError;
|
||||
@@ -13,6 +12,7 @@ use crate::server::metrics::{
|
||||
classify_stream_end, outcome_from_status, MetricsRegistry, RequestLogContext, RequestOutcome,
|
||||
StaleRequestOutcome, WorkerModeLabel,
|
||||
};
|
||||
use crate::state::load_monitor::router_inflight_load::RouterInflightLoadGuard;
|
||||
use crate::workers::{LoadGuard, Worker};
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response};
|
||||
@@ -24,7 +24,7 @@ use std::time::Instant;
|
||||
const CHAT_PATH: &str = "/v1/chat/completions";
|
||||
// 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");
|
||||
type LoadGuards = (LoadGuard, ActiveLoadGuard);
|
||||
type LoadGuards = (LoadGuard, RouterInflightLoadGuard);
|
||||
|
||||
/// A plain worker, or a prefill worker paired with a decode worker for PD.
|
||||
pub(super) struct SelectedWorkers {
|
||||
@@ -58,7 +58,7 @@ pub(super) async fn forward_chat_request(
|
||||
} else {
|
||||
prefill.load_guard()
|
||||
};
|
||||
let active_request_guard = ctx.active_load.register(
|
||||
let active_request_guard = ctx.router_inflight_load.register(
|
||||
prefill.id.clone(),
|
||||
prefill.url.clone(),
|
||||
request.input_token_count,
|
||||
@@ -97,7 +97,7 @@ pub(super) async fn forward_chat_request(
|
||||
);
|
||||
let decode_load_guards = (
|
||||
decode.load_guard(),
|
||||
ctx.active_load
|
||||
ctx.router_inflight_load
|
||||
.register(decode.id.clone(), decode.url.clone(), 0, 1),
|
||||
);
|
||||
(decode, decode_load_guards)
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
//! discovered" failure mode is observable.
|
||||
|
||||
use crate::discovery::WorkerMode;
|
||||
use crate::policies::kv_events::{KvIndexMetrics, Tiers, ACCOUNTING_REASONS};
|
||||
use crate::server::app_context::AppContext;
|
||||
use crate::server::metrics::{escape_label, WorkerSnapshot};
|
||||
use crate::state::kv_events::{KvIndexMetrics, Tiers, ACCOUNTING_REASONS};
|
||||
use axum::extract::State;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
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
|
||||
// underflow would wrap usize and render as a nonsensical
|
||||
// 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();
|
||||
@@ -188,7 +188,7 @@ mod tests {
|
||||
/// cell of the tally.
|
||||
#[tokio::test]
|
||||
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 w = KvWorkerId::new("http://w0:30000".into(), 0);
|
||||
@@ -231,7 +231,7 @@ mod tests {
|
||||
/// calls `clear_worker`.
|
||||
#[tokio::test]
|
||||
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 w = KvWorkerId::new("http://w0:30000".into(), 0);
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
/// all four families.
|
||||
#[tokio::test]
|
||||
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 tree = Arc::new(HashTree::new());
|
||||
|
||||
@@ -136,7 +136,7 @@ mod tests {
|
||||
},
|
||||
),
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
//! `compute_block_hashes` must hash with the **same** block size the
|
||||
//! worker uses to publish KV-cache events; otherwise every cache-aware
|
||||
//! 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
|
||||
//! `ModelDeploymentCard` populated by the worker registrar (see
|
||||
//! `~/dynamo/components/src/dynamo/sglang/register.py`); a mismatch
|
||||
+29
-28
@@ -7,7 +7,7 @@
|
||||
//! always operate together in production:
|
||||
//!
|
||||
//! - [`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
|
||||
//! the cache topic, one on the load topic.
|
||||
//! - 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::tree::{HashTree, KvWorkerId, Tiers};
|
||||
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.
|
||||
///
|
||||
@@ -96,13 +96,13 @@ pub struct KvEventIndex {
|
||||
maintain_tree: bool,
|
||||
subscribers: Arc<KvEventSubscriberRegistry>,
|
||||
/// 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
|
||||
/// subscribers for the same worker don't collide.
|
||||
load_subscribers: Arc<KvEventSubscriberRegistry>,
|
||||
/// Engine-reported per-worker load, written by the pump from
|
||||
/// `WorkerEvent::Load` and captured at request ingress.
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
engine_reported_load: Arc<EngineReportedLoadTable>,
|
||||
pump: Mutex<Option<JoinHandle<()>>>,
|
||||
pump_cancel: CancellationToken,
|
||||
workers: Mutex<HashMap<String, WorkerEntry>>,
|
||||
@@ -177,14 +177,14 @@ impl KvEventIndex {
|
||||
let (tx, rx) = mpsc::channel::<WorkerEvent>(EVENT_CHANNEL_BUFFER);
|
||||
let subscribers = Arc::new(KvEventSubscriberRegistry::new(tx.clone()));
|
||||
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 live_workers: Arc<Mutex<HashSet<KvWorkerId>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
let pump_cancel = CancellationToken::new();
|
||||
let tally = Arc::new(EventTally::new());
|
||||
let pump = tokio::spawn(pump_loop(
|
||||
tree.clone(),
|
||||
engine_load.clone(),
|
||||
engine_reported_load.clone(),
|
||||
cursors.clone(),
|
||||
live_workers.clone(),
|
||||
Arc::clone(&tally),
|
||||
@@ -196,7 +196,7 @@ impl KvEventIndex {
|
||||
maintain_tree,
|
||||
subscribers,
|
||||
load_subscribers,
|
||||
engine_load,
|
||||
engine_reported_load,
|
||||
pump: Mutex::new(Some(pump)),
|
||||
pump_cancel,
|
||||
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
|
||||
/// (from `LoadStat` events); `add_worker` / `remove_worker` here manage
|
||||
/// the expected set and per-worker eviction.
|
||||
pub fn engine_load(&self) -> Arc<EngineLoadTable> {
|
||||
Arc::clone(&self.engine_load)
|
||||
pub fn engine_reported_load(&self) -> Arc<EngineReportedLoadTable> {
|
||||
Arc::clone(&self.engine_reported_load)
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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.
|
||||
if !load_dp_ranks.is_empty() {
|
||||
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;
|
||||
}
|
||||
@@ -412,7 +413,7 @@ impl KvEventIndex {
|
||||
// 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
|
||||
// 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();
|
||||
for id in &ids {
|
||||
self.tree.clear_worker(id);
|
||||
@@ -457,7 +458,7 @@ impl KvEventIndex {
|
||||
/// restarting from seq=1 (after sending END_SEQ) is not filtered.
|
||||
async fn pump_loop(
|
||||
tree: Arc<HashTree>,
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
engine_reported_load: Arc<EngineReportedLoadTable>,
|
||||
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
|
||||
live_workers: Arc<Mutex<HashSet<KvWorkerId>>>,
|
||||
tally: Arc<EventTally>,
|
||||
@@ -497,7 +498,7 @@ async fn pump_loop(
|
||||
WorkerEvent::Load { worker, load } => {
|
||||
// Gauge: last value wins, no sequence/dedup. The live-worker
|
||||
// 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 } => {
|
||||
if cursors.lock().remove(&worker).is_some() {
|
||||
@@ -585,8 +586,8 @@ async fn pump_loop(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::policies::engine_load::LoadStat;
|
||||
use crate::policies::kv_events::wire::{BlockRemoved, BlockStored, KvEventBatch};
|
||||
use crate::state::kv_events::wire::{BlockRemoved, BlockStored, KvEventBatch};
|
||||
use crate::state::load_monitor::engine_reported_load::LoadStat;
|
||||
|
||||
fn worker_id(url: &str, rank: u32) -> KvWorkerId {
|
||||
KvWorkerId {
|
||||
@@ -607,7 +608,7 @@ mod tests {
|
||||
/// can destructure just the bits they need.
|
||||
struct PumpHarness {
|
||||
tree: Arc<HashTree>,
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
engine_reported_load: Arc<EngineReportedLoadTable>,
|
||||
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
|
||||
tally: Arc<EventTally>,
|
||||
#[allow(dead_code)]
|
||||
@@ -622,7 +623,7 @@ mod tests {
|
||||
/// the given workers pre-marked live.
|
||||
fn spawn_pump(live: &[KvWorkerId]) -> PumpHarness {
|
||||
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 live_set: Arc<Mutex<HashSet<KvWorkerId>>> =
|
||||
Arc::new(Mutex::new(live.iter().cloned().collect()));
|
||||
@@ -631,7 +632,7 @@ mod tests {
|
||||
let (tx, rx) = mpsc::channel(4);
|
||||
let pump = tokio::spawn(pump_loop(
|
||||
tree.clone(),
|
||||
engine_load.clone(),
|
||||
engine_reported_load.clone(),
|
||||
cursors.clone(),
|
||||
live_set.clone(),
|
||||
Arc::clone(&tally),
|
||||
@@ -640,7 +641,7 @@ mod tests {
|
||||
));
|
||||
PumpHarness {
|
||||
tree,
|
||||
engine_load,
|
||||
engine_reported_load,
|
||||
cursors,
|
||||
tally,
|
||||
live_set,
|
||||
@@ -833,7 +834,7 @@ mod tests {
|
||||
async fn pump_applies_load_to_engine_load_table() {
|
||||
let id = worker_id("http://w1", 0);
|
||||
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 {
|
||||
worker: id.clone(),
|
||||
@@ -850,7 +851,7 @@ mod tests {
|
||||
drop(tx);
|
||||
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();
|
||||
assert_eq!(load.num_running_reqs + load.num_waiting_reqs, 12);
|
||||
// Load events must not pollute the cache tree.
|
||||
@@ -1105,7 +1106,7 @@ mod tests {
|
||||
assert_eq!(oracle.get(), Some(64));
|
||||
assert!(oracle.is_bigram());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1128,10 +1129,10 @@ mod tests {
|
||||
is_bigram: false,
|
||||
};
|
||||
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();
|
||||
index.engine_load().set(
|
||||
index.engine_reported_load().set(
|
||||
url,
|
||||
0,
|
||||
LoadStat {
|
||||
@@ -1144,7 +1145,7 @@ mod tests {
|
||||
now,
|
||||
);
|
||||
assert!(index
|
||||
.engine_load()
|
||||
.engine_reported_load()
|
||||
.capture_snapshot(now)
|
||||
.fresh_load_for_url(url)
|
||||
.is_some());
|
||||
@@ -1152,13 +1153,13 @@ mod tests {
|
||||
index.remove_worker(url).await;
|
||||
assert!(
|
||||
index
|
||||
.engine_load()
|
||||
.engine_reported_load()
|
||||
.capture_snapshot(Instant::now())
|
||||
.fresh_load_for_url(url)
|
||||
.is_none(),
|
||||
"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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@
|
||||
//! consumed by [`super::index::KvEventIndex`]. Each `(worker_url, dp_rank)`
|
||||
//! pair gets its own SUB socket on its own tokio task, decodes msgpack frames
|
||||
//! 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.
|
||||
//!
|
||||
//! # Wire format (3-frame multipart)
|
||||
@@ -72,7 +72,7 @@ use zeromq::{Socket, SocketRecv, SubSocket, ZmqMessage};
|
||||
use super::discovery::EventConfig;
|
||||
use super::tree::KvWorkerId;
|
||||
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
|
||||
/// gives up and exits its task. ZMQ's internal reconnect handles transient
|
||||
@@ -665,7 +665,7 @@ mod tests {
|
||||
use tokio::time::timeout;
|
||||
use zeromq::{Endpoint, PubSocket, Socket, SocketSend, ZmqMessage};
|
||||
|
||||
use crate::policies::kv_events::wire::KvCacheEvent;
|
||||
use crate::state::kv_events::wire::KvCacheEvent;
|
||||
|
||||
mod helpers {
|
||||
use super::*;
|
||||
+35
-33
@@ -10,7 +10,7 @@
|
||||
//! 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
|
||||
//! semantics. Entries older than [`EngineLoadTable::freshness`] are ignored.
|
||||
//! semantics. Entries older than [`EngineReportedLoadTable::freshness`] are ignored.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
@@ -58,12 +58,12 @@ pub struct LoadStat {
|
||||
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 oldest rank timestamp so later local dispatches can be added.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EngineWorkerLoad {
|
||||
pub struct EngineReportedWorkerLoad {
|
||||
pub num_running_reqs: u64,
|
||||
pub num_waiting_reqs: u64,
|
||||
pub num_tokens: u64,
|
||||
@@ -71,12 +71,14 @@ pub struct EngineWorkerLoad {
|
||||
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
|
||||
/// DP rank. Initial samples and counter resets leave both values unavailable.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NativeCacheWorkerLoad {
|
||||
pub struct EngineReportedSchedulingLoad {
|
||||
pub num_running_reqs: u64,
|
||||
pub num_waiting_reqs: u64,
|
||||
pub num_waiting_uncached_tokens: u64,
|
||||
@@ -89,19 +91,19 @@ pub struct NativeCacheWorkerLoad {
|
||||
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
|
||||
/// workers are omitted and must use Router-local active load.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EngineLoadSnapshot {
|
||||
pub struct EngineReportedLoadSnapshot {
|
||||
pub version: u64,
|
||||
workers: HashMap<String, EngineWorkerLoad>,
|
||||
native_cache_workers: HashMap<String, NativeCacheWorkerLoad>,
|
||||
workers: HashMap<String, EngineReportedWorkerLoad>,
|
||||
native_cache_workers: HashMap<String, EngineReportedSchedulingLoad>,
|
||||
}
|
||||
|
||||
impl EngineLoadSnapshot {
|
||||
pub fn fresh_load_for_url(&self, worker_url: &str) -> Option<&EngineWorkerLoad> {
|
||||
impl EngineReportedLoadSnapshot {
|
||||
pub fn fresh_load_for_url(&self, worker_url: &str) -> Option<&EngineReportedWorkerLoad> {
|
||||
self.workers.get(worker_url)
|
||||
}
|
||||
|
||||
@@ -136,13 +138,13 @@ impl EngineLoadSnapshot {
|
||||
pub fn fresh_native_cache_load_for_url(
|
||||
&self,
|
||||
worker_url: &str,
|
||||
) -> Option<&NativeCacheWorkerLoad> {
|
||||
) -> Option<&EngineReportedSchedulingLoad> {
|
||||
self.native_cache_workers.get(worker_url)
|
||||
}
|
||||
|
||||
/// Builds a view from worker data that already passed freshness and rank checks.
|
||||
/// Production requests should use [`EngineLoadTable::capture_snapshot`].
|
||||
pub fn from_workers(version: u64, workers: HashMap<String, EngineWorkerLoad>) -> Self {
|
||||
/// Production requests should use [`EngineReportedLoadTable::capture_snapshot`].
|
||||
pub fn from_workers(version: u64, workers: HashMap<String, EngineReportedWorkerLoad>) -> Self {
|
||||
Self {
|
||||
version,
|
||||
workers,
|
||||
@@ -151,17 +153,17 @@ impl EngineLoadSnapshot {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
version: u64,
|
||||
workers: HashMap<String, NativeCacheWorkerLoad>,
|
||||
workers: HashMap<String, EngineReportedSchedulingLoad>,
|
||||
) -> Self {
|
||||
let basic = workers
|
||||
.iter()
|
||||
.map(|(url, load)| {
|
||||
(
|
||||
url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: load.num_running_reqs,
|
||||
num_waiting_reqs: load.num_waiting_reqs,
|
||||
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
|
||||
/// subscriber pump and captured once at request ingress.
|
||||
#[derive(Debug)]
|
||||
pub struct EngineLoadTable {
|
||||
pub struct EngineReportedLoadTable {
|
||||
by_rank: DashMap<(String, u32), LoadEntry>,
|
||||
/// Per-rank publishers the worker advertised. A worker is usable only
|
||||
/// when every advertised rank has a fresh value; accepting a partial
|
||||
@@ -302,7 +304,7 @@ pub struct EngineLoadTable {
|
||||
version: AtomicU64,
|
||||
}
|
||||
|
||||
impl EngineLoadTable {
|
||||
impl EngineReportedLoadTable {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
by_rank: DashMap::new(),
|
||||
@@ -364,7 +366,7 @@ impl EngineLoadTable {
|
||||
/// look misleadingly idle and draw *more* traffic.) Callers that never
|
||||
/// registered expected ranks retain the all-known-ranks rule. The oldest
|
||||
/// 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).
|
||||
let mut observed: HashMap<String, HashMap<u32, (LoadStat, bool, Instant)>> = HashMap::new();
|
||||
for entry in self.by_rank.iter() {
|
||||
@@ -412,7 +414,7 @@ impl EngineLoadTable {
|
||||
oldest_at.map(|captured_at| {
|
||||
(
|
||||
url,
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs,
|
||||
num_waiting_reqs,
|
||||
num_tokens,
|
||||
@@ -432,7 +434,7 @@ impl EngineLoadTable {
|
||||
fn fresh_native_cache_worker_loads(
|
||||
&self,
|
||||
now: Instant,
|
||||
) -> HashMap<String, NativeCacheWorkerLoad> {
|
||||
) -> HashMap<String, EngineReportedSchedulingLoad> {
|
||||
let mut observed: HashMap<String, NativeWorkerObservations> = HashMap::new();
|
||||
for entry in self.by_rank.iter() {
|
||||
let at = entry.value().at;
|
||||
@@ -521,7 +523,7 @@ impl EngineLoadTable {
|
||||
oldest_at.map(|captured_at| {
|
||||
(
|
||||
url,
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs,
|
||||
num_waiting_reqs,
|
||||
num_waiting_uncached_tokens,
|
||||
@@ -540,8 +542,8 @@ impl EngineLoadTable {
|
||||
}
|
||||
|
||||
/// Captures one immutable view for all routing decisions in a request.
|
||||
pub fn capture_snapshot(&self, now: Instant) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot {
|
||||
pub fn capture_snapshot(&self, now: Instant) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot {
|
||||
version: self.version.load(Ordering::Acquire),
|
||||
workers: self.fresh_worker_loads(now),
|
||||
native_cache_workers: self.fresh_native_cache_worker_loads(now),
|
||||
@@ -635,7 +637,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sums_queue_depth_across_ranks() {
|
||||
let t = EngineLoadTable::new();
|
||||
let t = EngineReportedLoadTable::new();
|
||||
let now = Instant::now();
|
||||
t.set("http://w:30000", 0, load(5, 1), now);
|
||||
t.set("http://w:30000", 1, load(3, 2), now);
|
||||
@@ -647,7 +649,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
t.set("http://w:30000", 0, load(9, 9), old);
|
||||
// A read far in the future sees the entry as stale -> worker absent.
|
||||
@@ -660,7 +662,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn forget_worker_clears_all_ranks() {
|
||||
let t = EngineLoadTable::new();
|
||||
let t = EngineReportedLoadTable::new();
|
||||
let now = Instant::now();
|
||||
t.set("http://w:30000", 0, 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.
|
||||
#[test]
|
||||
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 stale = now - Duration::from_secs(3600);
|
||||
t.set("http://w:30000", 0, load(5, 1), now); // fresh
|
||||
@@ -692,7 +694,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn missing_expected_rank_excludes_worker() {
|
||||
let t = EngineLoadTable::new();
|
||||
let t = EngineReportedLoadTable::new();
|
||||
let now = Instant::now();
|
||||
t.mark_expected_rank("http://w:30000", 0);
|
||||
t.mark_expected_rank("http://w:30000", 1);
|
||||
@@ -712,7 +714,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 later = earlier + Duration::from_secs(1);
|
||||
t.set("http://w:30000", 0, load(5, 1), later);
|
||||
@@ -726,7 +728,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn expected_count_tracks_marked_workers_and_forget() {
|
||||
let t = EngineLoadTable::new();
|
||||
let t = EngineReportedLoadTable::new();
|
||||
assert_eq!(t.expected_count(), 0);
|
||||
t.mark_expected_rank("http://w:30000", 0);
|
||||
t.mark_expected_rank("http://w:30000", 1); // same worker
|
||||
@@ -738,7 +740,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn complete_v3_semantic_samples_derive_prefill_queue_time() {
|
||||
let t = EngineLoadTable::new();
|
||||
let t = EngineReportedLoadTable::new();
|
||||
let first = Instant::now();
|
||||
let second = first + Duration::from_secs(2);
|
||||
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;
|
||||
+38
-32
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// 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.
|
||||
//!
|
||||
//! 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
|
||||
//! count) separately from decode (block count). The two counters share
|
||||
//! 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.
|
||||
//!
|
||||
//! # Drop semantics
|
||||
@@ -34,7 +34,7 @@
|
||||
//!
|
||||
//! # 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`;
|
||||
//! tests use a `MockClock`. The `Instant`-based timestamp on registration
|
||||
//! is sufficient for the timeout comparison (monotonic), so the clock
|
||||
@@ -42,7 +42,7 @@
|
||||
//! type whose `duration_since(other)` returns the wall-clock delta.
|
||||
|
||||
use crate::discovery::WorkerId;
|
||||
use crate::server::metrics::{ActiveLoadKind, MetricsRegistry};
|
||||
use crate::server::metrics::{MetricsRegistry, RouterInflightLoadKind};
|
||||
use dashmap::DashMap;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -52,7 +52,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Eq, Hash, PartialEq, Debug)]
|
||||
pub struct RequestId(pub Uuid);
|
||||
@@ -86,7 +86,7 @@ struct WorkerCounters {
|
||||
///
|
||||
/// `cancel` is a [`CancellationToken`] the janitor fires when the entry
|
||||
/// 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 —
|
||||
/// surfacing the stale-request expiry as a 504 to the client instead
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// [`Self::sweep_stale`] to evict requests that outlived
|
||||
/// `stale_request_timeout`.
|
||||
#[derive(Debug)]
|
||||
pub struct ActiveLoadRegistry {
|
||||
pub struct RouterInflightLoadRegistry {
|
||||
workers: DashMap<WorkerId, Arc<WorkerCounters>>,
|
||||
requests: DashMap<RequestId, RequestEntry>,
|
||||
clock: Arc<dyn Clock>,
|
||||
@@ -185,8 +185,8 @@ pub struct ActiveLoadRegistry {
|
||||
metrics: Mutex<Option<Arc<MetricsRegistry>>>,
|
||||
}
|
||||
|
||||
impl ActiveLoadRegistry {
|
||||
/// Construct an [`ActiveLoadRegistry`] wrapped in an [`Arc`].
|
||||
impl RouterInflightLoadRegistry {
|
||||
/// Construct an [`RouterInflightLoadRegistry`] wrapped in an [`Arc`].
|
||||
///
|
||||
/// The registry is always shared (proxy + janitor + selector all hold
|
||||
/// the same instance), so the public constructor mints the `Arc`
|
||||
@@ -221,14 +221,14 @@ impl ActiveLoadRegistry {
|
||||
let Some(metrics) = self.metrics.lock().clone() else {
|
||||
return;
|
||||
};
|
||||
metrics.set_active_load(
|
||||
metrics.set_router_inflight_load(
|
||||
worker_url,
|
||||
ActiveLoadKind::PrefillTokens,
|
||||
RouterInflightLoadKind::PrefillTokens,
|
||||
counters.prefill_load.load(Ordering::Relaxed) as i64,
|
||||
);
|
||||
metrics.set_active_load(
|
||||
metrics.set_router_inflight_load(
|
||||
worker_url,
|
||||
ActiveLoadKind::DecodeBlocks,
|
||||
RouterInflightLoadKind::DecodeBlocks,
|
||||
counters.decode_load.load(Ordering::Relaxed) as i64,
|
||||
);
|
||||
}
|
||||
@@ -262,7 +262,7 @@ impl ActiveLoadRegistry {
|
||||
worker_url: impl Into<String>,
|
||||
prefill_load: usize,
|
||||
decode_load: usize,
|
||||
) -> ActiveLoadGuard {
|
||||
) -> RouterInflightLoadGuard {
|
||||
let worker_url = worker_url.into();
|
||||
let request_id = RequestId::new_v4();
|
||||
let counters = self
|
||||
@@ -291,7 +291,7 @@ impl ActiveLoadRegistry {
|
||||
cancel: cancel.clone(),
|
||||
},
|
||||
);
|
||||
ActiveLoadGuard {
|
||||
RouterInflightLoadGuard {
|
||||
registry: Some(Arc::clone(self)),
|
||||
request_id: Some(request_id),
|
||||
worker,
|
||||
@@ -398,7 +398,7 @@ impl ActiveLoadRegistry {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// `Arc<ActiveLoadRegistry>` (cloned from the shared one held in
|
||||
/// `Arc<RouterInflightLoadRegistry>` (cloned from the shared one held in
|
||||
/// `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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// guard on the same line and decrement the counter before the request
|
||||
/// actually executed, defeating the purpose. The compile-time warning
|
||||
/// 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)]
|
||||
pub struct ActiveLoadGuard {
|
||||
registry: Option<Arc<ActiveLoadRegistry>>,
|
||||
pub struct RouterInflightLoadGuard {
|
||||
registry: Option<Arc<RouterInflightLoadRegistry>>,
|
||||
/// `None` after the janitor expired this request — drop becomes a
|
||||
/// no-op in that case. The guard keeps only the `RequestId`; the
|
||||
/// per-axis amounts (and the captured `Arc<WorkerCounters>`) live
|
||||
@@ -505,7 +508,7 @@ pub struct ActiveLoadGuard {
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl ActiveLoadGuard {
|
||||
impl RouterInflightLoadGuard {
|
||||
/// Read-only accessor (mainly for tests + diagnostic logging).
|
||||
pub fn worker(&self) -> &WorkerId {
|
||||
&self.worker
|
||||
@@ -520,7 +523,7 @@ impl ActiveLoadGuard {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ActiveLoadGuard {
|
||||
impl Drop for RouterInflightLoadGuard {
|
||||
fn drop(&mut self) {
|
||||
// If the janitor already expired this request (or `expire_now` was
|
||||
// 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
|
||||
// and wins, we skip the decrement here. Decrement the **same**
|
||||
// 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
|
||||
// across `forget_worker` + re-register cycles.
|
||||
if let Some((_, entry)) = registry.requests.remove(&id) {
|
||||
@@ -553,9 +556,12 @@ mod tests {
|
||||
use super::*;
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -617,7 +623,7 @@ mod tests {
|
||||
/// Gap closer #2: double-drop safety.
|
||||
///
|
||||
/// 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
|
||||
/// registry's own bookkeeping never under-decrements, even if the
|
||||
/// janitor and a guard's drop race. We assert that by simulating the
|
||||
@@ -688,7 +694,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn spawn_janitor_sweeps_stale_entries() {
|
||||
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 _g = registry.register(w.clone(), "test://50-2", 50, 2);
|
||||
assert_eq!(registry.inflight_count(), 1);
|
||||
@@ -708,7 +714,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn spawn_janitor_shutdown_is_clean() {
|
||||
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(®istry), Duration::from_millis(100));
|
||||
// Verify shutdown completes within a generous bound.
|
||||
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
|
||||
/// 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
|
||||
/// `sgl_router_active_load` permanently at 0 in production.
|
||||
#[test]
|
||||
@@ -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;
|
||||
@@ -186,7 +186,7 @@ mod tests {
|
||||
},
|
||||
),
|
||||
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 url::Url;
|
||||
|
||||
use crate::policies::kv_events::EventConfig;
|
||||
use crate::state::kv_events::EventConfig;
|
||||
|
||||
/// Default timeout for `/server_info`. Conservative for a small JSON
|
||||
/// payload served by SGLang's HTTP server.
|
||||
@@ -148,7 +148,7 @@ impl WorkerIntrospector {
|
||||
|
||||
// EAGLE-family speculative decoding ⇒ the worker hashes KV blocks over
|
||||
// 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(),
|
||||
worker_url,
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
use crate::config::Config;
|
||||
use crate::discovery::{DiscoveryEvent, ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use crate::health::circuit_breaker::CircuitBreakerConfig;
|
||||
use crate::policies::active_load::ActiveLoadRegistry;
|
||||
use crate::policies::kv_events::KvEventIndex;
|
||||
use crate::state::kv_events::KvEventIndex;
|
||||
use crate::state::load_monitor::router_inflight_load::RouterInflightLoadRegistry;
|
||||
use crate::workers::introspect::{DisaggregationRole, WorkerIntrospector};
|
||||
use crate::workers::{WireProtocol, WorkerRegistry};
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// worker — fine for tests, but production passes `Some(...)`); when
|
||||
/// `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>,
|
||||
cfg: Option<Arc<Config>>,
|
||||
kv_index: Option<Arc<KvEventIndex>>,
|
||||
active_load: Option<Arc<ActiveLoadRegistry>>,
|
||||
router_inflight_load: Option<Arc<RouterInflightLoadRegistry>>,
|
||||
) {
|
||||
run_with_introspector(
|
||||
rx,
|
||||
registry,
|
||||
cfg,
|
||||
kv_index,
|
||||
active_load,
|
||||
router_inflight_load,
|
||||
Arc::new(WorkerIntrospector::default()),
|
||||
)
|
||||
.await
|
||||
@@ -146,7 +146,7 @@ pub async fn run_with_introspector(
|
||||
registry: Arc<WorkerRegistry>,
|
||||
cfg: Option<Arc<Config>>,
|
||||
kv_index: Option<Arc<KvEventIndex>>,
|
||||
active_load: Option<Arc<ActiveLoadRegistry>>,
|
||||
router_inflight_load: Option<Arc<RouterInflightLoadRegistry>>,
|
||||
introspector: Arc<WorkerIntrospector>,
|
||||
) {
|
||||
run_with_introspector_and_reconcile(
|
||||
@@ -154,7 +154,7 @@ pub async fn run_with_introspector(
|
||||
registry,
|
||||
cfg,
|
||||
kv_index,
|
||||
active_load,
|
||||
router_inflight_load,
|
||||
introspector,
|
||||
RECONCILE_INTERVAL,
|
||||
)
|
||||
@@ -186,7 +186,7 @@ pub async fn run_with_introspector_and_reconcile(
|
||||
registry: Arc<WorkerRegistry>,
|
||||
cfg: Option<Arc<Config>>,
|
||||
kv_index: Option<Arc<KvEventIndex>>,
|
||||
active_load: Option<Arc<ActiveLoadRegistry>>,
|
||||
router_inflight_load: Option<Arc<RouterInflightLoadRegistry>>,
|
||||
introspector: Arc<WorkerIntrospector>,
|
||||
reconcile_interval: Duration,
|
||||
) {
|
||||
@@ -226,7 +226,7 @@ pub async fn run_with_introspector_and_reconcile(
|
||||
®istry,
|
||||
&cfg,
|
||||
&kv_index,
|
||||
&active_load,
|
||||
&router_inflight_load,
|
||||
&introspector,
|
||||
&mut pending,
|
||||
)
|
||||
@@ -264,7 +264,7 @@ async fn handle_discovery_event(
|
||||
registry: &Arc<WorkerRegistry>,
|
||||
cfg: &Option<Arc<Config>>,
|
||||
kv_index: &Option<Arc<KvEventIndex>>,
|
||||
active_load: &Option<Arc<ActiveLoadRegistry>>,
|
||||
router_inflight_load: &Option<Arc<RouterInflightLoadRegistry>>,
|
||||
introspector: &Arc<WorkerIntrospector>,
|
||||
pending: &mut HashMap<WorkerId, JoinHandle<()>>,
|
||||
) {
|
||||
@@ -327,7 +327,7 @@ async fn handle_discovery_event(
|
||||
// per-worker counters slot will not be re-created
|
||||
// (selectors no longer see the worker, so no new
|
||||
// requests can register against it).
|
||||
if let Some(al) = active_load {
|
||||
if let Some(al) = router_inflight_load {
|
||||
al.forget_worker(&id);
|
||||
}
|
||||
}
|
||||
@@ -570,7 +570,7 @@ async fn register_one(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{
|
||||
ActiveLoadConfig, CircuitBreakerConfig as RawCbConfig, DiscoveryBackend, ModelConfig,
|
||||
CircuitBreakerConfig as RawCbConfig, DiscoveryBackend, InflightLoadConfig, ModelConfig,
|
||||
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use crate::discovery::{WorkerId, WorkerMode};
|
||||
@@ -610,7 +610,7 @@ mod tests {
|
||||
urls: vec!["http://test:30000".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// churn would leak one `WorkerCounters` entry per departed worker.
|
||||
#[tokio::test]
|
||||
@@ -984,14 +984,14 @@ mod tests {
|
||||
spawn_fake_server_info_worker(json!({"served_model_name": "m"})).await;
|
||||
|
||||
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 manager_handle = tokio::spawn(run_with_introspector(
|
||||
rx,
|
||||
registry.clone(),
|
||||
None,
|
||||
None,
|
||||
Some(Arc::clone(&active_load)),
|
||||
Some(Arc::clone(&router_inflight_load)),
|
||||
fast_introspector(),
|
||||
));
|
||||
|
||||
@@ -1019,8 +1019,8 @@ mod tests {
|
||||
|
||||
// Mint a guard to force the active-load registry to create a
|
||||
// per-worker counters slot for this id.
|
||||
let _g = active_load.register(id.clone(), "test://", 10, 1);
|
||||
assert!(active_load.is_known(&id));
|
||||
let _g = router_inflight_load.register(id.clone(), "test://", 10, 1);
|
||||
assert!(router_inflight_load.is_known(&id));
|
||||
|
||||
// Now drive the Removed event and assert the counters slot is
|
||||
// gone. We tear down the guard last so the request entry is
|
||||
@@ -1030,7 +1030,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let removed = timeout(Duration::from_secs(2), async {
|
||||
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;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
@@ -1039,7 +1039,7 @@ mod tests {
|
||||
.await;
|
||||
assert!(
|
||||
removed.is_ok(),
|
||||
"manager must call active_load.forget_worker on Removed",
|
||||
"manager must call router_inflight_load.forget_worker on Removed",
|
||||
);
|
||||
|
||||
drop(tx);
|
||||
|
||||
@@ -77,7 +77,7 @@ impl WorkerRegistry {
|
||||
/// spec carries an id that already has an entry, the prior entry
|
||||
/// stays put — it's the caller's responsibility to decide whether
|
||||
/// 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
|
||||
/// a caller actually wanted to keep the prior entry.
|
||||
pub fn add_with_cb(
|
||||
@@ -463,7 +463,7 @@ mod tests {
|
||||
/// On a rejected upsert with `MixedPdAndPlain`, the registry is
|
||||
/// **not** mutated — the prior entry for the rejected id stays
|
||||
/// 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.
|
||||
#[test]
|
||||
fn upsert_rejected_with_mixed_modes_leaves_registry_unchanged() {
|
||||
|
||||
@@ -246,7 +246,7 @@ impl Worker {
|
||||
self.protocol
|
||||
}
|
||||
|
||||
pub fn active_load(&self) -> usize {
|
||||
pub fn router_inflight_load(&self) -> usize {
|
||||
self.active_requests.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ impl std::fmt::Debug for Worker {
|
||||
.field("url", &self.url)
|
||||
.field("mode", &self.mode())
|
||||
.field("protocol", &self.protocol)
|
||||
.field("active_load", &self.active_load())
|
||||
.field("router_inflight_load", &self.router_inflight_load())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -304,15 +304,15 @@ mod tests {
|
||||
model_ids: vec![ModelId("m".into())],
|
||||
bootstrap_port: None,
|
||||
});
|
||||
assert_eq!(w.active_load(), 0);
|
||||
assert_eq!(w.router_inflight_load(), 0);
|
||||
let g = w.load_guard();
|
||||
assert_eq!(w.active_load(), 1);
|
||||
assert_eq!(w.router_inflight_load(), 1);
|
||||
let g2 = w.load_guard();
|
||||
assert_eq!(w.active_load(), 2);
|
||||
assert_eq!(w.router_inflight_load(), 2);
|
||||
drop(g);
|
||||
assert_eq!(w.active_load(), 1);
|
||||
assert_eq!(w.router_inflight_load(), 1);
|
||||
drop(g2);
|
||||
assert_eq!(w.active_load(), 0);
|
||||
assert_eq!(w.router_inflight_load(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -321,11 +321,11 @@ mod tests {
|
||||
let cutoff = Instant::now() - Duration::from_secs(1);
|
||||
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);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(w.active_load(), 0);
|
||||
assert_eq!(w.router_inflight_load(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -334,11 +334,11 @@ mod tests {
|
||||
let cutoff = Instant::now() - Duration::from_secs(1);
|
||||
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);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(w.active_load(), 0);
|
||||
assert_eq!(w.router_inflight_load(), 0);
|
||||
assert_eq!(w.slots_acquired_since(cutoff), 0);
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ mod tests {
|
||||
let cutoff = Instant::now();
|
||||
let _g_new1 = 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!(
|
||||
w.slots_acquired_since(cutoff),
|
||||
2,
|
||||
|
||||
@@ -90,7 +90,8 @@ async fn static_urls_pd_role_resolved_end_to_end() {
|
||||
use axum::{routing::get, Json, Router};
|
||||
use serde_json::json;
|
||||
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::workers::{manager, WorkerRegistry};
|
||||
@@ -146,7 +147,7 @@ async fn static_urls_pd_role_resolved_end_to_end() {
|
||||
urls: vec![url.clone()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
};
|
||||
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
|
||||
@@ -5,10 +5,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
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::state::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId};
|
||||
|
||||
#[test]
|
||||
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,
|
||||
DecodeSelectionContext, LegacyHostAffinityDecodePolicy,
|
||||
};
|
||||
use sgl_router::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
|
||||
use sgl_router::policies::SelectionProposal;
|
||||
use sgl_router::state::load_monitor::engine_reported_load::{
|
||||
EngineReportedLoadSnapshot, EngineReportedSchedulingLoad,
|
||||
};
|
||||
use sgl_router::workers::Worker;
|
||||
use std::collections::HashMap;
|
||||
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 {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineReportedLoadSnapshot {
|
||||
EngineReportedLoadSnapshot::from_native_cache_workers(
|
||||
7,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, running, waiting, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
EngineReportedSchedulingLoad {
|
||||
num_running_reqs: *running,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_waiting_uncached_tokens: *waiting,
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
//! and the idlest at 1.0, so every assertion below holds either way.
|
||||
|
||||
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::scoring::{
|
||||
prefix_cache::PrefixCachePolicy, FusedScorePolicy, ScorePolicy,
|
||||
};
|
||||
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 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")];
|
||||
// Local counters changed after the request snapshot and prefer w0.
|
||||
let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
let snapshot = EngineReportedLoadSnapshot::from_workers(
|
||||
29,
|
||||
HashMap::from([
|
||||
(
|
||||
ws[0].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 50,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -97,7 +97,7 @@ fn fused_load_based_term_uses_the_request_snapshot() {
|
||||
),
|
||||
(
|
||||
ws[1].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 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() {
|
||||
let ws = vec![worker("w0"), worker("w1")];
|
||||
let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
let snapshot = EngineReportedLoadSnapshot::from_workers(
|
||||
31,
|
||||
HashMap::from([
|
||||
(
|
||||
ws[0].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 50,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
@@ -138,7 +138,7 @@ fn score_policy_forwards_the_request_snapshot_to_load_based() {
|
||||
),
|
||||
(
|
||||
ws[1].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
EngineReportedWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
//! whatever fixture is checked in.
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
//! 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
|
||||
//! drive `insert` / `remove` / `clear_worker` against the same tree from
|
||||
//! tokio worker threads while the chat handler simultaneously calls
|
||||
@@ -25,7 +25,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use sgl_router::policies::kv_events::{HashTree, KvWorkerId};
|
||||
use sgl_router::state::kv_events::{HashTree, KvWorkerId};
|
||||
|
||||
fn worker(i: usize) -> KvWorkerId {
|
||||
KvWorkerId {
|
||||
|
||||
@@ -22,8 +22,8 @@ use std::time::Duration;
|
||||
|
||||
use zeromq::SocketSend;
|
||||
|
||||
use sgl_router::policies::kv_events::discovery::EventConfig;
|
||||
use sgl_router::policies::kv_events::{compute_block_hashes, KvEventIndex, KvWorkerId};
|
||||
use sgl_router::state::kv_events::discovery::EventConfig;
|
||||
use sgl_router::state::kv_events::{compute_block_hashes, KvEventIndex, KvWorkerId};
|
||||
|
||||
use super::zmq_helpers::{
|
||||
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-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
|
||||
//! the two-frame `[seq, payload]` ZMQ message a real publisher sends, and
|
||||
//! binds a loopback PUB socket on an OS-assigned port.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use serde::Deserialize;
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::ModelId;
|
||||
@@ -128,7 +128,7 @@ fn registry(model_id: &str, tokenizer_path: PathBuf) -> TokenizerRegistry {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
};
|
||||
TokenizerRegistry::load_from_config(&cfg).unwrap()
|
||||
}
|
||||
|
||||
@@ -131,17 +131,17 @@ fn load_guard_decrements_on_panic_unwind() {
|
||||
model_ids: vec![ModelId("m".into())],
|
||||
bootstrap_port: None,
|
||||
}));
|
||||
assert_eq!(w.active_load(), 0);
|
||||
assert_eq!(w.router_inflight_load(), 0);
|
||||
|
||||
let w_inner = w.clone();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||
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");
|
||||
}));
|
||||
assert!(result.is_err(), "the closure must have panicked");
|
||||
assert_eq!(
|
||||
w.active_load(),
|
||||
w.router_inflight_load(),
|
||||
0,
|
||||
"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.
|
||||
#[tokio::test]
|
||||
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!({
|
||||
"served_model_name": "m",
|
||||
|
||||
@@ -10,17 +10,17 @@ use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use sgl_kv_indexer::{PrefixIndex, PrefixIndexError, PrefixMatch, PrefixOutcome};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, AffinityConfig, BucketConfig, BucketSpec, BucketStage, CacheAwareConfig,
|
||||
CachePrefixProvider, Config, DiscoveryBackend, KvIndexerEndpointConfig, ModelConfig,
|
||||
AffinityConfig, BucketConfig, BucketSpec, BucketStage, CacheAwareConfig, CachePrefixProvider,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, KvIndexerEndpointConfig, ModelConfig,
|
||||
ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode,
|
||||
SloBucketPolicy, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
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::proxy::Proxy;
|
||||
use sgl_router::server::app::build_router;
|
||||
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::workers::WorkerRegistry;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -77,7 +77,7 @@ fn build_app_context(
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
};
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&config).unwrap());
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
@@ -236,7 +236,7 @@ fn set_native_load_with_waiting(
|
||||
max_total_num_tokens: u64,
|
||||
num_waiting_uncached_tokens: u64,
|
||||
) {
|
||||
ctx.engine_load.set(
|
||||
ctx.engine_reported_load.set(
|
||||
worker_url,
|
||||
0,
|
||||
LoadStat {
|
||||
|
||||
@@ -18,10 +18,10 @@ use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::factory::build_registry;
|
||||
use sgl_router::policies::kv_events::{BlockSizeOracle, HashTree};
|
||||
use sgl_router::proxy::Proxy;
|
||||
use sgl_router::server::app::build_router;
|
||||
use sgl_router::server::app_context::AppContext;
|
||||
use sgl_router::state::kv_events::{BlockSizeOracle, HashTree};
|
||||
use sgl_router::tokenizer::TokenizerRegistry;
|
||||
use sgl_router::workers::WorkerRegistry;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
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()],
|
||||
}),
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
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);
|
||||
|
||||
// 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
|
||||
.workers_for(&ModelId("tiny".into()))
|
||||
.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.
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
assert!(
|
||||
w_handle.active_load() >= 1,
|
||||
w_handle.router_inflight_load() >= 1,
|
||||
"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.
|
||||
@@ -1398,25 +1398,25 @@ async fn streaming_load_guard_persists_for_body_lifetime() {
|
||||
// released. Give the spawned task a brief moment to clean up.
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
assert_eq!(
|
||||
w_handle.active_load(),
|
||||
w_handle.router_inflight_load(),
|
||||
0,
|
||||
"load should be 0 after stream completes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Task A: the chat handler mints an `ActiveLoadGuard` from the shared
|
||||
/// `ActiveLoadRegistry` and drops it when the request completes. The
|
||||
/// Task A: the chat handler mints an `RouterInflightLoadGuard` from the shared
|
||||
/// `RouterInflightLoadRegistry` and drops it when the request completes. The
|
||||
/// non-streaming path drops the guard on handler exit; this test
|
||||
/// asserts the round-trip increment → 0 across a single request.
|
||||
#[tokio::test]
|
||||
async fn non_streaming_active_load_increments_then_returns_to_zero() {
|
||||
let worker = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
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);
|
||||
|
||||
assert_eq!(
|
||||
active_load.inflight_count(),
|
||||
router_inflight_load.inflight_count(),
|
||||
0,
|
||||
"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
|
||||
// dropped — counters are back to zero.
|
||||
assert_eq!(
|
||||
active_load.inflight_count(),
|
||||
router_inflight_load.inflight_count(),
|
||||
0,
|
||||
"active-load registry must be empty after non-streaming handler returns",
|
||||
);
|
||||
let w_id = WorkerId("w1".into());
|
||||
assert_eq!(
|
||||
active_load.prefill_load(&w_id),
|
||||
router_inflight_load.prefill_load(&w_id),
|
||||
0,
|
||||
"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`;
|
||||
/// after the body drains it returns to 0. Counterpart to
|
||||
/// `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 proxy = Arc::new(Proxy::new(TEST_TIMEOUT).unwrap());
|
||||
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 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.
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
assert!(
|
||||
active_load.inflight_count() >= 1,
|
||||
router_inflight_load.inflight_count() >= 1,
|
||||
"registry inflight must be >= 1 mid-stream, got {}",
|
||||
active_load.inflight_count(),
|
||||
router_inflight_load.inflight_count(),
|
||||
);
|
||||
let w_id = WorkerId("w1".into());
|
||||
assert!(
|
||||
active_load.prefill_load(&w_id) >= 1,
|
||||
router_inflight_load.prefill_load(&w_id) >= 1,
|
||||
"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.
|
||||
@@ -1524,12 +1524,12 @@ async fn streaming_active_load_persists_for_body_lifetime() {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
|
||||
assert_eq!(
|
||||
active_load.inflight_count(),
|
||||
router_inflight_load.inflight_count(),
|
||||
0,
|
||||
"registry must be empty after stream drains",
|
||||
);
|
||||
assert_eq!(
|
||||
active_load.prefill_load(&w_id),
|
||||
router_inflight_load.prefill_load(&w_id),
|
||||
0,
|
||||
"prefill_load must be 0 after stream drains",
|
||||
);
|
||||
@@ -1555,7 +1555,7 @@ async fn streaming_active_load_drops_on_client_disconnect() {
|
||||
)
|
||||
.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.
|
||||
use futures::StreamExt;
|
||||
@@ -1570,7 +1570,7 @@ async fn streaming_active_load_drops_on_client_disconnect() {
|
||||
wait_for_metric(&ctx, &expected).await;
|
||||
|
||||
assert_eq!(
|
||||
active_load.inflight_count(),
|
||||
router_inflight_load.inflight_count(),
|
||||
0,
|
||||
"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`.
|
||||
///
|
||||
/// 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
|
||||
/// longer than the timeout. The janitor sweeps before the upstream
|
||||
/// returns; cancellation fires; handler returns 504.
|
||||
#[tokio::test]
|
||||
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
|
||||
// stale_request_timeout.
|
||||
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
|
||||
// tick (every 20ms) and fire the cancellation token before the
|
||||
// upstream returns.
|
||||
let active_load = ActiveLoadRegistry::new(
|
||||
Arc::new(sgl_router::policies::active_load::SystemTimeClock),
|
||||
let router_inflight_load = RouterInflightLoadRegistry::new(
|
||||
Arc::new(sgl_router::state::load_monitor::router_inflight_load::SystemTimeClock),
|
||||
Duration::from_millis(50),
|
||||
);
|
||||
let _janitor = spawn_janitor(Arc::clone(&active_load), Duration::from_millis(20));
|
||||
let ctx = Arc::new(AppContext::with_active_load(
|
||||
let _janitor = spawn_janitor(Arc::clone(&router_inflight_load), Duration::from_millis(20));
|
||||
let ctx = Arc::new(AppContext::with_router_inflight_load(
|
||||
cfg,
|
||||
tokenizers,
|
||||
proxy,
|
||||
registry,
|
||||
policies,
|
||||
active_load,
|
||||
router_inflight_load,
|
||||
));
|
||||
let app = build_router(ctx);
|
||||
|
||||
@@ -1671,7 +1673,7 @@ async fn non_streaming_error_path_drops_active_load_guard() {
|
||||
drop(listener);
|
||||
|
||||
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 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.
|
||||
let _ = res.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(
|
||||
active_load.inflight_count(),
|
||||
router_inflight_load.inflight_count(),
|
||||
0,
|
||||
"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.
|
||||
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig,
|
||||
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
CacheAwareConfig, Config, DiscoveryBackend, InflightLoadConfig, ModelConfig,
|
||||
ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
|
||||
pub const MODEL: &str = "deepseek-v4-tiny";
|
||||
@@ -42,6 +42,6 @@ pub fn config() -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
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::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
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::proxy::Proxy;
|
||||
use sgl_router::server::app::build_router;
|
||||
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::workers::WorkerRegistry;
|
||||
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()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
};
|
||||
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
use futures::future::join_all;
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
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()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
};
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
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
|
||||
/// 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
|
||||
/// same blind spot `active_load.inflight_count()` has, reproduced in the
|
||||
/// same blind spot `router_inflight_load.inflight_count()` has, reproduced in the
|
||||
/// replacement.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
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`,
|
||||
/// `/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
|
||||
/// drain report back into noise.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
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()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
};
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
|
||||
@@ -21,7 +21,7 @@ use axum::http::{Request, StatusCode};
|
||||
use bytes::Bytes;
|
||||
use serde_json::{json, Value};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
@@ -62,7 +62,7 @@ fn config() -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
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()));
|
||||
|
||||
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!(request.await.unwrap().unwrap().status(), StatusCode::OK);
|
||||
|
||||
@@ -20,7 +20,7 @@ use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
@@ -61,7 +61,7 @@ fn config() -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
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_util::rt::{TokioExecutor, TokioIo};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
@@ -149,7 +149,7 @@ fn config() -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
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::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
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::request_tokens_for;
|
||||
use sgl_router::proxy::Proxy;
|
||||
use sgl_router::server::app::build_router;
|
||||
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::workers::WorkerRegistry;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -11,7 +11,7 @@ use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
@@ -59,7 +59,7 @@ fn config() -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
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::http::{Request, StatusCode};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::engine_load::{LoadStat, NativeCacheRankLoad};
|
||||
use sgl_router::policies::{
|
||||
CacheCandidate, CacheCandidateProposal, Policy, PolicyRegistry, PrefillProposal, ProposalKind,
|
||||
SelectionContext, SelectionProposal,
|
||||
@@ -19,6 +18,7 @@ use sgl_router::policies::{
|
||||
use sgl_router::proxy::Proxy;
|
||||
use sgl_router::server::app::build_router;
|
||||
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::workers::{Worker, WorkerRegistry};
|
||||
use tower::ServiceExt;
|
||||
@@ -165,7 +165,7 @@ fn config(policy: PolicyKind) -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
};
|
||||
fixture.ctx.engine_load.set(
|
||||
fixture.ctx.engine_reported_load.set(
|
||||
&fixture.workers[0].url,
|
||||
0,
|
||||
native_load(1, 1),
|
||||
@@ -315,7 +315,7 @@ async fn chat_commits_the_admitted_prefill_backup() {
|
||||
);
|
||||
fixture
|
||||
.ctx
|
||||
.engine_load
|
||||
.engine_reported_load
|
||||
.set(&fixture.workers[0].url, 0, native_load(2, 2), now);
|
||||
|
||||
assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK);
|
||||
@@ -351,7 +351,7 @@ async fn capacity_exhaustion_does_not_return_503() {
|
||||
})
|
||||
.await;
|
||||
for worker in &fixture.workers {
|
||||
fixture.ctx.engine_load.set(
|
||||
fixture.ctx.engine_reported_load.set(
|
||||
&worker.url,
|
||||
0,
|
||||
LoadStat {
|
||||
@@ -434,7 +434,7 @@ async fn chat_records_cache_candidates_exhausted() {
|
||||
})
|
||||
})
|
||||
.await;
|
||||
fixture.ctx.engine_load.set(
|
||||
fixture.ctx.engine_reported_load.set(
|
||||
&fixture.workers[0].url,
|
||||
0,
|
||||
LoadStat {
|
||||
|
||||
@@ -24,7 +24,7 @@ use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
@@ -77,7 +77,7 @@ fn config() -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! `MockWorker` backends (CPU-only, no GPU).
|
||||
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind,
|
||||
};
|
||||
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()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
};
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
|
||||
@@ -14,7 +14,7 @@ use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
Config, DiscoveryBackend, InflightLoadConfig, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
@@ -55,7 +55,7 @@ fn config(_worker_url: &str) -> Config {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
router_inflight_load: InflightLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user