[Router] Add bucket-aware policy domains and native cache indexing (#38108)

Signed-off-by: Vincent Gao <vincentbo@linux.alibaba.com>
Co-authored-by: inkcherry <mingzhi.liu@amd.com>
Co-authored-by: yangbodong22011 <13137470+yangbodong22011@users.noreply.github.com>
This commit is contained in:
Vincent Gao
2026-09-06 19:47:51 +08:00
committed by GitHub
co-authored by inkcherry yangbodong22011
parent a176ba2f7b
commit 5bebe7a033
76 changed files with 5842 additions and 3639 deletions
@@ -130,6 +130,8 @@ async fn static_urls_pd_role_resolved_end_to_end() {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: sgl_router::config::PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -0,0 +1,356 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Static buckets define candidate domains and fallback order. Worker scoring,
//! admission, and guards remain the responsibility of the P/D policies.
use sgl_router::config::{BucketConfig, BucketSpec, BucketStage, SloBucketPolicy};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::buckets::{BucketRequest, BucketSelector};
use sgl_router::policies::CacheCandidate;
use sgl_router::workers::Worker;
use std::sync::Arc;
fn worker(id: &str, mode: WorkerMode) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}:30000"),
mode,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}))
}
fn bucket(id: &str, stage: BucketStage, rank: u32, worker_ids: &[&str]) -> BucketSpec {
BucketSpec {
id: id.into(),
stage,
rank,
worker_ids: worker_ids.iter().map(|id| (*id).into()).collect(),
min_extend_tokens: None,
max_extend_tokens: None,
min_sequence_tokens: None,
max_sequence_tokens: None,
max_context_tokens: None,
ttft_p95_at_capacity_ms: None,
tps_p05_at_capacity: None,
max_pending_prefill_tokens: None,
}
}
#[test]
fn prefill_slo_first_tries_eligible_buckets_by_rank_before_degrading() {
let fast = worker("fast", WorkerMode::Prefill);
let cheap = worker("cheap", WorkerMode::Prefill);
let mut cheap_bucket = bucket("cheap", BucketStage::Prefill, 10, &["cheap"]);
cheap_bucket.ttft_p95_at_capacity_ms = Some(300);
let mut fast_bucket = bucket("fast", BucketStage::Prefill, 20, &["fast"]);
fast_bucket.ttft_p95_at_capacity_ms = Some(80);
let selector = BucketSelector::new(Some(BucketConfig {
buckets: vec![cheap_bucket, fast_bucket],
ttft_slo_policy: SloBucketPolicy::SloFirst,
tps_slo_policy: SloBucketPolicy::Disabled,
}));
let domains = selector.prefill_domains(
&[cheap, fast],
BucketRequest {
input_tokens: 256,
expected_peak_sequence_tokens: None,
ttft_slo_ms: Some(100),
tps_slo: None,
},
);
assert_eq!(
domains
.iter()
.map(|domain| domain.id.as_str())
.collect::<Vec<_>>(),
["fast", "cheap"],
"eligible buckets come first; non-eligible buckets are the explicit SLO-degraded fallback"
);
}
#[test]
fn prefill_best_effort_tries_non_slo_bucket_before_reserved_slo_capacity() {
let fast = worker("fast", WorkerMode::Prefill);
let cheap = worker("cheap", WorkerMode::Prefill);
let mut fast_bucket = bucket("fast", BucketStage::Prefill, 10, &["fast"]);
fast_bucket.ttft_p95_at_capacity_ms = Some(80);
let mut cheap_bucket = bucket("cheap", BucketStage::Prefill, 20, &["cheap"]);
cheap_bucket.ttft_p95_at_capacity_ms = Some(300);
let selector = BucketSelector::new(Some(BucketConfig {
buckets: vec![fast_bucket, cheap_bucket],
ttft_slo_policy: SloBucketPolicy::BestEffort,
tps_slo_policy: SloBucketPolicy::Disabled,
}));
let domains = selector.prefill_domains(
&[fast, cheap],
BucketRequest {
input_tokens: 256,
expected_peak_sequence_tokens: None,
ttft_slo_ms: Some(100),
tps_slo: None,
},
);
assert_eq!(
domains
.iter()
.map(|domain| domain.id.as_str())
.collect::<Vec<_>>(),
["cheap", "fast"],
"best-effort tries non-SLO capacity first and retains the SLO tier as fallback"
);
}
#[test]
fn cache_candidate_uses_uncached_work_range_but_full_context_and_own_ttft_profile() {
let short = worker("short", WorkerMode::Prefill);
let long = worker("long", WorkerMode::Prefill);
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, &["short"]);
short_bucket.max_extend_tokens = Some(64);
short_bucket.max_context_tokens = Some(4_096);
short_bucket.ttft_p95_at_capacity_ms = Some(80);
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, &["long"]);
long_bucket.min_extend_tokens = Some(65);
long_bucket.max_context_tokens = Some(4_096);
long_bucket.ttft_p95_at_capacity_ms = Some(300);
let selector = BucketSelector::new(Some(BucketConfig {
buckets: vec![short_bucket, long_bucket],
ttft_slo_policy: SloBucketPolicy::SloFirst,
tps_slo_policy: SloBucketPolicy::Disabled,
}));
let workers = vec![Arc::clone(&short), Arc::clone(&long)];
let request = BucketRequest {
input_tokens: 256,
expected_peak_sequence_tokens: None,
ttft_slo_ms: Some(100),
tps_slo: None,
};
assert_eq!(
selector
.prefill_domains(&workers, request)
.iter()
.map(|domain| domain.id.as_str())
.collect::<Vec<_>>(),
["p-long"],
"no-hit target selection uses E=L for extend-work compatibility"
);
let short_hit = CacheCandidate {
worker: Arc::clone(&short),
matched_prefix_tokens: 224,
uncached_tokens: 32,
candidate_range_id: "global".into(),
max_pending_prefill_tokens: None,
};
let bound = selector
.bind_prefill_cache_candidate(short_hit, request)
.expect("E=32 fits short work range and the full L=256 fits max context");
assert_eq!(bound.candidate_range_id, "p-short");
let long_hit = CacheCandidate {
worker: Arc::clone(&long),
matched_prefix_tokens: 0,
uncached_tokens: 256,
candidate_range_id: "global".into(),
max_pending_prefill_tokens: None,
};
assert!(
selector
.bind_prefill_cache_candidate(long_hit, request)
.is_none(),
"a cache candidate whose own Hard TTFT profile misses the request SLO is rejected"
);
}
#[test]
fn cache_candidate_without_bucket_configuration_keeps_global_metadata() {
let p = worker("p", WorkerMode::Prefill);
let selector = BucketSelector::new(None);
let candidate = CacheCandidate {
worker: p,
matched_prefix_tokens: 64,
uncached_tokens: 64,
candidate_range_id: "probe".into(),
max_pending_prefill_tokens: Some(1),
};
let bound = selector
.bind_prefill_cache_candidate(
candidate,
BucketRequest {
input_tokens: 128,
expected_peak_sequence_tokens: None,
ttft_slo_ms: None,
tps_slo: None,
},
)
.expect("Step 1 always has a catch-all domain");
assert_eq!(bound.candidate_range_id, "global");
assert_eq!(bound.max_pending_prefill_tokens, None);
}
#[test]
fn decode_bucket_uses_peak_sequence_length_then_tps_profile_and_rank() {
let short = worker("short", WorkerMode::Decode);
let long = worker("long", WorkerMode::Decode);
let mut short_bucket = bucket("short", BucketStage::Decode, 10, &["short"]);
short_bucket.max_sequence_tokens = Some(1_024);
short_bucket.tps_p05_at_capacity = Some(80.0);
let mut long_bucket = bucket("long", BucketStage::Decode, 20, &["long"]);
long_bucket.max_sequence_tokens = Some(8_192);
long_bucket.tps_p05_at_capacity = Some(40.0);
let selector = BucketSelector::new(Some(BucketConfig {
buckets: vec![short_bucket, long_bucket],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::SloFirst,
}));
let domains = selector.decode_domains(
&[short, long],
BucketRequest {
input_tokens: 256,
expected_peak_sequence_tokens: Some(900),
ttft_slo_ms: None,
tps_slo: Some(60.0),
},
);
assert_eq!(domains.len(), 2);
assert_eq!(domains[0].id, "short");
assert_eq!(domains[1].id, "long");
}
#[test]
fn missing_bucket_configuration_keeps_the_global_domain() {
let p = worker("p", WorkerMode::Prefill);
let d = worker("d", WorkerMode::Decode);
let selector = BucketSelector::new(None);
let facts = BucketRequest {
input_tokens: 128,
expected_peak_sequence_tokens: Some(512),
ttft_slo_ms: Some(100),
tps_slo: Some(20.0),
};
let prefill = selector.prefill_domains(&[p], facts);
let decode = selector.decode_domains(&[d], facts);
assert_eq!(prefill.len(), 1);
assert_eq!(prefill[0].id, "global");
assert_eq!(decode.len(), 1);
assert_eq!(decode[0].id, "global");
}
#[test]
fn prefill_only_bucket_configuration_keeps_the_global_decode_domain() {
let p = worker("p", WorkerMode::Prefill);
let d = worker("d", WorkerMode::Decode);
let selector = BucketSelector::new(Some(BucketConfig {
buckets: vec![bucket("p", BucketStage::Prefill, 10, &["p"])],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
}));
let facts = BucketRequest {
input_tokens: 128,
expected_peak_sequence_tokens: Some(512),
ttft_slo_ms: None,
tps_slo: None,
};
assert_eq!(selector.prefill_domains(&[p], facts)[0].id, "p");
let decode = selector.decode_domains(&[d], facts);
assert_eq!(decode.len(), 1);
assert_eq!(decode[0].id, "global");
}
#[test]
fn decode_catch_all_still_rejects_input_beyond_runtime_context() {
let d = worker("d", WorkerMode::Decode);
let mut catch_all = bucket("d-catch-all", BucketStage::Decode, 10, &["d"]);
catch_all.max_context_tokens = Some(1_024);
let selector = BucketSelector::new(Some(BucketConfig {
buckets: vec![catch_all],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
}));
let domains = selector.decode_domains(
&[d],
BucketRequest {
input_tokens: 2_048,
expected_peak_sequence_tokens: None,
ttft_slo_ms: None,
tps_slo: None,
},
);
assert!(
domains.is_empty(),
"an unknown output budget does not erase the known input context requirement"
);
}
#[test]
fn membership_index_preserves_exact_matching_and_fleet_order() {
let workers: Vec<_> = (0..10)
.map(|index| worker(&format!("w{index}"), WorkerMode::Prefill))
.collect();
let scan = bucket("scan", BucketStage::Prefill, 10, &["w3", "W3", "w1", "w1"]);
let set = bucket(
"set",
BucketStage::Prefill,
20,
&[
"w9", "w3", "w1", "w1", "W3", " w2", "absent-0", "absent-1", "absent-2",
],
);
let selector = BucketSelector::new(Some(BucketConfig {
buckets: vec![scan, set],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
}));
let request = BucketRequest {
input_tokens: 128,
expected_peak_sequence_tokens: None,
ttft_slo_ms: None,
tps_slo: None,
};
let domains = selector.prefill_domains(&workers, request);
let ids = |index: usize| {
domains[index]
.workers
.iter()
.map(|worker| worker.id.0.as_str())
.collect::<Vec<_>>()
};
assert_eq!(ids(0), ["w1", "w3"]);
assert_eq!(ids(1), ["w1", "w3", "w9"]);
let candidate = CacheCandidate {
worker: Arc::clone(&workers[9]),
matched_prefix_tokens: 0,
uncached_tokens: 128,
candidate_range_id: "global".into(),
max_pending_prefill_tokens: None,
};
assert_eq!(
selector
.bind_prefill_cache_candidate(candidate, request)
.expect("w9 belongs to the hash-indexed bucket")
.candidate_range_id,
"set"
);
assert_eq!(
selector
.prefill_affinity_domain(&workers, &workers[9], request)
.expect("w9 has a bucket affinity")
.id,
"set"
);
}
@@ -1,190 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! E2E test for the cache-aware-zmq policy.
//!
//! Drives a real ZMQ PUB socket → `KvEventIndex` subscriber pipeline →
//! `HashTree` → `CacheAwareZmqPolicy::select`. Verifies that an event
//! published by one worker's PUB causes subsequent selection to route
//! to that worker (cache-aware affinity).
//!
//! API constraint: the subscriber registry builds endpoints as
//! `tcp://{host}:{port_base + dp_rank}` where `port_base` is in the
//! per-worker `EventConfig`. Both mock workers below share
//! `127.0.0.1` as host, so both subscribe to the same PUB socket and
//! both end up indexed in the tree. The tiebreak (lowest active_load)
//! picks the worker we want; same shape as the SMG version of this
//! test.
use std::sync::Arc;
use std::time::Duration;
use zeromq::SocketSend;
use sgl_router::config::CacheAwareConfig;
use sgl_router::config::{ActiveLoadConfig, ProxyConfig};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::cache_aware_zmq::CacheAwareZmqPolicy;
use sgl_router::policies::engine_load::EngineLoadTable;
use sgl_router::policies::kv_events::{compute_block_hashes, discovery::EventConfig, KvEventIndex};
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::Worker;
use super::zmq_helpers::{
build_multipart, encode_block_stored_event, encode_event_batch, make_pub_bound,
};
fn build_worker(url: &str, model: &str) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(url.into()),
url: url.into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId(model.into())],
bootstrap_port: None,
}))
}
/// E2E: real PUB socket publishes a `BlockStored` for worker A's
/// hash chain. The `CacheAwareZmqPolicy`'s shared `KvEventIndex`
/// receives it, applies it to the tree, and the next `select` call
/// picks worker A.
///
/// Both workers share `127.0.0.1` as host so both subscribers connect
/// to the same PUB and both get indexed under their KvWorkerIds — the
/// same shape as the SMG e2e test. We tie-break on min-load: worker B
/// is bumped above worker A so the matched-worker pick prefers A.
#[tokio::test]
async fn zmq_indexer_routes_to_publishing_worker_e2e() {
let model_id = ModelId("tiny".into());
// 1. Tokenizer registry — use the in-tree tiny fixture.
let cfg = sgl_router::config::Config {
server: sgl_router::config::ServerConfig {
host: "0".into(),
port: 0,
},
observability: Default::default(),
model: sgl_router::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: sgl_router::config::PolicyKind::CacheAwareZmq,
circuit_breaker: None,
cache_aware: None,
sticky: None,
affinity: None,
fused: None,
eligibility: None,
},
discovery: sgl_router::config::DiscoveryBackend::StaticUrls(
sgl_router::config::StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
},
),
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
// 2. Bind a real PUB socket on an OS-assigned port.
let (mut pub_a, port) = make_pub_bound().await;
// 3. Compute the hash chain for the routing prompt.
let text = "hello world hello world hello world";
let tok = tokenizers.get("tiny").unwrap();
let token_ids = sgl_router::tokenizer::adapter::encode(&tok, text).unwrap();
let block_size = 4u32;
let hashes = compute_block_hashes(&token_ids, block_size as usize);
assert!(!hashes.is_empty(), "tiny tokenizer must yield ≥1 block");
// 4. Build the KvEventIndex + policy. The policy holds an
// Arc<HashTree> that the index also owns; events the index
// receives mutate the same tree the policy reads.
let kv_index = KvEventIndex::new();
// Mirror what `KvEventIndex::add_worker` would do in production: seed
// the oracle with the worker-reported page_size before any cache
// lookup happens. The integration path calls `add_worker` further
// down, but here we want the policy to know `block_size` immediately.
let block_size_oracle = kv_index.block_size_oracle();
block_size_oracle.try_set(block_size).unwrap();
let policy = CacheAwareZmqPolicy::new(
CacheAwareConfig {
cache_threshold: 0.0,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
kv_indexer_endpoint: None,
},
kv_index.tree(),
Arc::clone(&tokenizers),
block_size_oracle,
EngineLoadTable::new(),
);
// 5. Register two workers. They share `127.0.0.1` so both
// subscribers connect to the same PUB; preresolved EventConfig
// points at the bound port.
let url_a = "http://127.0.0.1:30000";
let url_b = "http://127.0.0.1:30001";
let preresolved = EventConfig {
host: "127.0.0.1".to_string(),
port_base: port,
topic: String::new(),
block_size,
dp_size: 1,
load_port_base: None,
load_topic: None,
is_bigram: false,
};
kv_index.add_worker(url_a, Some(preresolved.clone())).await;
kv_index.add_worker(url_b, Some(preresolved)).await;
// SUB sockets take a moment to handshake. The polling loop below
// soaks up any extra latency; this is just a publish-before-SUB
// guard.
tokio::time::sleep(Duration::from_millis(150)).await;
// 6. Publish a BlockStored event for the routing prompt's chain.
let event_bytes = encode_block_stored_event(&hashes, None, &token_ids, block_size);
let payload = encode_event_batch(0.0, vec![event_bytes], Some(0));
pub_a
.send(build_multipart(1, payload))
.await
.expect("send block-stored event");
// 7. Bump worker B's load so the tie-break picks A among matched
// workers. The bump stays below balance_abs_threshold so the
// imbalance fast-path does not skip cache-aware selection.
// Bind the guards to a Vec held for the rest of the test scope
// so the counter stays > 0 through the polling loop.
let w_a = build_worker(url_a, "tiny");
let w_b = build_worker(url_b, "tiny");
let _b_load: Vec<_> = (0..3).map(|_| w_b.load_guard()).collect();
let workers = vec![Arc::clone(&w_a), Arc::clone(&w_b)];
// 8. Drive select until the event has been applied. The pipeline is
// asynchronous (publish → SUB recv → mpsc → pump → tree); a
// polling loop is less flaky than a fixed sleep.
let body = serde_json::to_vec(&serde_json::json!({"prompt": text})).unwrap();
let ctx = SelectionContext::new(&model_id, Some(&body));
let start = std::time::Instant::now();
let mut chose_a = false;
while start.elapsed() < Duration::from_secs(3) {
if let Some(w) = policy.select(&workers, &ctx) {
if w.url == url_a {
chose_a = true;
break;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(
chose_a,
"policy did not route to publishing worker A within timeout",
);
// 9. Shutdown cleanly.
let r = tokio::time::timeout(Duration::from_secs(2), kv_index.shutdown()).await;
assert!(r.is_ok(), "kv_index shutdown should not hang");
}
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
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;
#[test]
fn radix_tree_reports_contiguous_prefix_depth_per_worker() {
let tokens = [11_u32, 12, 13, 14];
let hashes = compute_block_hashes(&tokens, 1);
let tree = Arc::new(HashTree::new());
let oracle = BlockSizeOracle::new();
oracle.try_set(1).unwrap();
tree.insert(&KvWorkerId::new("http://deep".into(), 0), None, &hashes);
tree.insert(
&KvWorkerId::new("http://deep".into(), 1),
None,
&hashes[..3],
);
tree.insert(
&KvWorkerId::new("http://shallow".into(), 0),
None,
&hashes[..2],
);
let signal = RadixTreePrefixProvider::new(tree, oracle)
.match_request_tokens(&tokens)
.expect("established local tree must produce a prefix signal");
let PrefixOutcome::Matched {
matches,
best_prefix_blocks,
} = signal.outcome
else {
panic!("local radix-tree hit must be normalized as a match");
};
let depth_by_url: HashMap<_, _> = matches
.into_iter()
.map(|entry| (entry.address, entry.matched_prefix_blocks))
.collect();
assert_eq!(signal.query_blocks, 4);
assert_eq!(best_prefix_blocks, 4);
assert_eq!(depth_by_url.get("http://deep"), Some(&4));
assert_eq!(depth_by_url.get("http://shallow"), Some(&2));
}
@@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Observable contract for decode policies.
//!
//! Decode guards require complete, fresh native monitor samples. Short frames
//! fall back to local load and must not appear as monitor-backed decisions.
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::admission::{resolve_decode, CandidateDomain, DecisionReason};
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::workers::Worker;
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Instant;
fn worker(id: &str) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}:30000"),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}))
}
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot {
EngineLoadSnapshot::from_native_cache_workers(
7,
entries
.iter()
.map(|(worker, running, waiting, used, capacity)| {
(
worker.url.clone(),
NativeCacheWorkerLoad {
num_running_reqs: *running,
num_waiting_reqs: *waiting,
num_waiting_uncached_tokens: *waiting,
num_used_tokens: *used,
num_total_tokens: *used,
max_total_num_tokens: *capacity,
max_running_requests: 64,
prefill_throughput_tokens_per_s: None,
estimated_prefill_queue_ms: None,
captured_at: Instant::now(),
},
)
})
.collect::<HashMap<_, _>>(),
)
}
#[test]
fn decode_p2_proposes_a_distinct_lower_pressure_primary_and_backup() {
let busy = worker("busy");
let idle = worker("idle");
busy.active_requests.store(8, Ordering::Relaxed);
idle.active_requests.store(1, Ordering::Relaxed);
let domain = CandidateDomain::global_decode(&[Arc::clone(&busy), Arc::clone(&idle)]);
let ctx = DecodeSelectionContext::new();
let proposal = DecodePowerOfTwoPolicy::new()
.propose(&domain, &ctx)
.expect("two decode candidates must produce a proposal");
assert_eq!(proposal.primary.id, idle.id);
assert_eq!(
proposal.backup.expect("P2 keeps the other sample").id,
busy.id
);
}
#[test]
fn legacy_host_affinity_remains_an_explicit_single_primary_compatibility_policy() {
let same_host = worker("host-a");
let other_host = worker("host-b");
let domain = CandidateDomain::global_decode(&[Arc::clone(&same_host), other_host]);
let ctx = DecodeSelectionContext::new().with_prefill_url("http://host-a:9999");
let proposal = LegacyHostAffinityDecodePolicy
.propose(&domain, &ctx)
.expect("legacy policy selects one compatible decode worker");
assert_eq!(proposal.primary.id, same_host.id);
assert!(
proposal.backup.is_none(),
"legacy semantics do not invent a backup"
);
}
#[test]
fn decode_admission_uses_backup_before_scanning_domain() {
let primary = worker("primary");
let backup = worker("backup");
let fallback = worker("fallback");
let domain = CandidateDomain::global_decode(&[
Arc::clone(&primary),
Arc::clone(&backup),
Arc::clone(&fallback),
]);
let loads = snapshot(&[
(&primary, 4, 0, 950, 1_000),
(&backup, 0, 0, 0, 1_000),
(&fallback, 0, 0, 0, 1_000),
]);
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup));
let decision =
resolve_decode(&domain, &proposal, 64, &loads).expect("admitted backup must be selected");
assert_eq!(decision.selected.id, backup.id);
assert_eq!(decision.reason, DecisionReason::BackupPrimaryAdmission);
}
#[test]
fn decode_guard_can_escape_a_primary_to_lower_dynamic_pressure_backup() {
let primary = worker("primary");
let backup = worker("backup");
let domain = CandidateDomain::global_decode(&[Arc::clone(&primary), Arc::clone(&backup)]);
let loads = snapshot(&[(&primary, 3, 2, 900, 2_000), (&backup, 1, 0, 100, 2_000)]);
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup));
let decision =
resolve_decode(&domain, &proposal, 64, &loads).expect("both candidates are admitted");
assert_eq!(decision.selected.id, backup.id);
assert_eq!(decision.reason, DecisionReason::BackupPressureGuard);
}
#[test]
fn decode_all_capacity_rejected_falls_back_to_power_of_two_within_domain() {
let primary = worker("primary");
let backup = worker("backup");
let workers = vec![Arc::clone(&primary), Arc::clone(&backup)];
let domain = CandidateDomain::global_decode(&workers);
let loads = snapshot(&[
(&primary, 0, 0, 1_000, 1_000),
(&backup, 0, 10, 1_000, 1_000),
]);
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup));
let decision = resolve_decode_with_capacity_fallback(&domain, &proposal, 64, &loads)
.expect("capacity exhaustion must degrade within the decode domain");
assert_eq!(decision.selected.id, primary.id);
assert_eq!(decision.reason, DecisionReason::CapacityFallbackPowerOfTwo);
}
@@ -88,7 +88,7 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
&& mb.workers.contains(&key);
if converged {
// Both trees agree on count AND on the worker that holds the
// prefix. This is what the cache-aware-zmq policy reads to
// prefix. This is what the Radix Tree provider reads to
// pick a worker; both routers picking the same key here
// means they would route the same prompt to the same worker.
assert_eq!(
@@ -3,7 +3,9 @@
mod zmq_helpers;
mod cache_aware_zmq;
mod bucket_domains;
mod cache_prefix_provider;
mod decode;
mod fused_score;
mod kv_events_hash_parity;
mod kv_events_tree_concurrent;
@@ -1,325 +0,0 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
# SPDX-License-Identifier: Apache-2.0
"""Content-based routing test for both cache-aware-zmq index backends.
Two SGLang workers publish KV events to two routers at once: one runs the
local ``KvEventIndex`` (SUB straight to the workers) and one runs against an
external KV Indexer fed by a ``kv-indexer-bridge`` per worker. Every
subscriber attaches before the single warmup, so one pair of disjoint
prefixes exercises both index backends without a second model load.
Assert on content, not on convergence: a broken event path degrades
``cache_aware_zmq`` to content-blind min-load, which routes both prefixes to
one worker and so fails at least one assertion below.
"""
from __future__ import annotations
import os
import re
import socket
import subprocess
import time
from contextlib import contextmanager
from pathlib import Path
import httpx
import pytest
from infra.gateway import Gateway
from infra.model_pool import spawn_worker
from infra.model_specs import get_model_spec
# Disjoint prefixes — share no common content. Under the chat template both
# render with the same leading role header (``<|im_start|>user`` ...; Qwen3 has
# no BOS token), so the first block(s) may hash identically; the disjoint
# content then diverges
# well within the matched region, making each worker's HashTree contribution
# uniquely identifying.
#
# Length matters: each prefix must span ≥2 SGLang blocks at the default
# block_size of 64 tokens so the worker actually emits BlockStored
# events. Below that, the publisher stays quiet and we'd be testing
# min-load by accident — the exact failure mode this test exists to
# rule out.
_PREFIX_X_BODY = (
"Apricot bouquet cinnamon dewdrop elderflower fennel garlic "
"hibiscus indigo jasmine kumquat lavender mint nutmeg oregano "
"paprika quince rosemary saffron tarragon. "
)
PREFIX_X = (_PREFIX_X_BODY * 8).strip()
_PREFIX_Y_BODY = (
"Zephyr yellow xylophone wombat vortex umbrella thistle saffron "
"quartz peppermint orchid nightshade marigold lemongrass kale "
"juniper iris hyacinth gardenia foxglove. "
)
PREFIX_Y = (_PREFIX_Y_BODY * 8).strip()
_REQ_TOTAL_RE = re.compile(
r"^sgl_router_worker_requests_total\{([^}]*)\}\s+(\d+(?:\.\d+)?)\s*$"
)
_LABEL_RE = re.compile(r'(\w+)="([^"]*)"')
def _open_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@contextmanager
def _run(binary: Path, env: dict[str, str], log_path: Path):
with log_path.open("w") as log:
process = subprocess.Popen(
[str(binary)],
env={**os.environ, **env},
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
yield process
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def _wait_for_indexer(process: subprocess.Popen, port: int, log_path: Path) -> None:
deadline = time.time() + 10
while time.time() < deadline:
if process.poll() is not None:
raise RuntimeError(
f"KV Indexer exited during startup:\n{log_path.read_text()}"
)
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
return
except OSError:
time.sleep(0.1)
raise RuntimeError("timed out waiting for KV Indexer")
def _wait_for_bridge(process: subprocess.Popen, log_path: Path) -> None:
deadline = time.time() + 10
while time.time() < deadline:
output = log_path.read_text(errors="replace")
if "bridge session established" in output:
# ZMQ connect is asynchronous; let the subscription reach the PUB.
time.sleep(0.5)
return
if process.poll() is not None:
raise RuntimeError(f"KV Indexer Bridge exited during startup:\n{output}")
time.sleep(0.1)
raise RuntimeError(f"timed out waiting for KV Indexer Bridge:\n{output}")
def _dump_logs(logs: dict[str, Path]) -> None:
"""Print the tail of each Indexer/Bridge log so a routing failure is debuggable."""
for name, path in logs.items():
tail = path.read_text(errors="replace")[-4000:] if path.exists() else "<no log>"
print(f"\n----- {name} -----\n{tail}")
def _success_counts_by_worker(router_url: str) -> dict[str, int]:
"""Scrape ``/metrics`` and return ``{worker_url: success_count}``."""
r = httpx.get(f"{router_url}/metrics", timeout=5.0)
r.raise_for_status()
counts: dict[str, int] = {}
for line in r.text.splitlines():
m = _REQ_TOTAL_RE.match(line)
if not m:
continue
labels = dict(_LABEL_RE.findall(m.group(1)))
if labels.get("outcome") != "success":
continue
worker = labels.get("worker_url")
if not worker:
continue
try:
counts[worker] = counts.get(worker, 0) + int(float(m.group(2)))
except ValueError:
continue
return counts
def _send_chat(url: str, model_id: str, prompt: str) -> int:
"""POST one chat completion; return the HTTP status."""
r = httpx.post(
f"{url}/v1/chat/completions",
json={
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 4,
"stream": False,
},
timeout=60.0,
)
return r.status_code
def _direct_warm(worker_url: str, model_id: str, prefix: str) -> None:
"""Send one ``/v1/chat/completions`` request with ``prefix`` DIRECTLY to a worker.
The KV-event publisher emits ``BlockStored`` as the request's
prompt blocks commit to that worker's cache; routers subscribed to
the publisher receive the event and add ``(block_hash → worker)``
entries to their ``HashTree``. The test then exercises those
entries by routing through the router.
Direct-warming (rather than going through a router) is the load-
bearing detail: routing through a router would itself choose which
worker to populate, so the two workers' HashTree state would no
longer be uniquely identifying.
Token alignment with the router — the workers run with the model's
real chat template (no override), so the engine caches blocks keyed
on chat-templated tokens (role markers + content + generation prompt).
``cache_aware_zmq`` mirrors this: for a chat request on a model that
ships a chat template, it renders the same template and tokenizes the
result before hashing, so warm and route hash the same blocks.
"""
r = httpx.post(
f"{worker_url}/v1/chat/completions",
json={
"model": model_id,
"messages": [{"role": "user", "content": prefix}],
"max_tokens": 4,
"stream": False,
},
timeout=60.0,
)
assert r.status_code == 200, (
f"direct warm to {worker_url} failed: HTTP {r.status_code} {r.text!r}"
)
def _route_through(router_url: str, model_id: str, prompt: str) -> str:
"""Send one request through ``router_url``; return which worker handled it.
Computed by diffing the per-worker success-counter on ``/metrics``
around the call. Asserts exactly one worker absorbed the request
(no partial counts, no cancellation race).
"""
before = _success_counts_by_worker(router_url)
code = _send_chat(router_url, model_id, prompt)
assert code == 200, f"request to {router_url} failed: HTTP {code}"
after = _success_counts_by_worker(router_url)
deltas = {w: after.get(w, 0) - before.get(w, 0) for w in set(after) | set(before)}
winners = [w for w, d in deltas.items() if d > 0]
assert len(winners) == 1, (
f"expected exactly one worker delta on {router_url}, got {deltas}"
)
return winners[0]
@pytest.mark.real_gpu
@pytest.mark.slow
def test_routers_route_by_prefix_content(
router_binary,
gpu_allocator,
tmp_path,
):
"""Both the local ZMQ index and the external Indexer must route by content."""
spec = get_model_spec("qwen3-0.6b")
gpus = gpu_allocator.acquire(2)
indexer_port = _open_port()
indexer_endpoint = f"http://127.0.0.1:{indexer_port}"
indexer_binary = router_binary.parent / "kv-indexer-server"
bridge_binary = router_binary.parent / "kv-indexer-bridge"
logs = {
name: tmp_path / f"{name}.log" for name in ("indexer", "bridge-x", "bridge-y")
}
try:
with (
spawn_worker(
"qwen3-0.6b",
gpu_ids=[gpus[0]],
enable_kv_events=True,
) as worker_x,
spawn_worker(
"qwen3-0.6b",
gpu_ids=[gpus[1]],
enable_kv_events=True,
) as worker_y,
_run(
indexer_binary,
{"KV_INDEXER_LISTEN_ADDR": f"127.0.0.1:{indexer_port}"},
logs["indexer"],
) as indexer,
):
_wait_for_indexer(indexer, indexer_port, logs["indexer"])
worker_urls = [worker_x.url, worker_y.url]
def bridge_env(worker, worker_id: str) -> dict[str, str]:
assert worker.kv_events_endpoint is not None
return {
"KV_INDEXER_WORKER_ID": worker_id,
"KV_INDEXER_WORKER_ADDRESS": worker.url,
"KV_INDEXER_ENDPOINT": indexer_endpoint,
"SGLANG_KV_EVENT_ENDPOINT": worker.kv_events_endpoint.replace(
"*", "127.0.0.1"
),
"SGLANG_KV_EVENT_TOPIC": "kv",
}
with (
_run(
bridge_binary, bridge_env(worker_x, "worker-x"), logs["bridge-x"]
) as bridge_x,
_run(
bridge_binary, bridge_env(worker_y, "worker-y"), logs["bridge-y"]
) as bridge_y,
Gateway() as local,
Gateway() as external,
):
local.start_regular(
model_id=spec["model"],
tokenizer_path=spec["model"],
worker_urls=worker_urls,
policy="cache_aware_zmq",
timeout=120.0,
)
external.start_regular(
model_id=spec["model"],
tokenizer_path=spec["model"],
worker_urls=worker_urls,
policy="cache_aware_zmq",
kv_indexer_endpoint=indexer_endpoint,
timeout=120.0,
)
_wait_for_bridge(bridge_x, logs["bridge-x"])
_wait_for_bridge(bridge_y, logs["bridge-y"])
_direct_warm(worker_x.url, spec["model"], PREFIX_X)
_direct_warm(worker_y.url, spec["model"], PREFIX_Y)
time.sleep(2.0)
try:
for router, label in (
(local, "local-index"),
(external, "external-indexer"),
):
landed = _route_through(
router.base_url, spec["model"], PREFIX_X
)
assert landed == worker_x.url, (
f"router {label}: PREFIX_X must route to {worker_x.url}; landed on {landed}"
)
landed = _route_through(
router.base_url, spec["model"], PREFIX_Y
)
assert landed == worker_y.url, (
f"router {label}: PREFIX_Y must route to {worker_y.url}; landed on {landed}"
)
except Exception:
_dump_logs(logs)
raise
finally:
gpu_allocator.release(gpus)
@@ -186,7 +186,7 @@ class Gateway:
each worker's mode (plain) and any disaggregation
metadata are learned from ``/server_info``.
policy: Policy kind — ``round_robin``, ``random``, ``power_of_two``,
or ``cache_aware_zmq``.
or ``cache_aware``.
kv_indexer_endpoint: Optional external KV Indexer gRPC endpoint.
timeout: How long to wait for ``/readyz`` before giving up.
"""
@@ -0,0 +1,35 @@
{
"ttft_slo_policy": "disabled",
"tps_slo_policy": "disabled",
"buckets": [
{
"id": "short",
"stage": "prefill",
"rank": 0,
"worker_ids": [
"http://127.0.0.1:31000",
"http://127.0.0.1:31001",
"http://127.0.0.1:31002",
"http://127.0.0.1:31003"
],
"min_extend_tokens": 0,
"max_extend_tokens": 2048,
"max_context_tokens": 32768,
"max_pending_prefill_tokens": 65536
},
{
"id": "long",
"stage": "prefill",
"rank": 1,
"worker_ids": [
"http://127.0.0.1:31004",
"http://127.0.0.1:31005",
"http://127.0.0.1:31006",
"http://127.0.0.1:31007"
],
"min_extend_tokens": 2049,
"max_context_tokens": 32768,
"max_pending_prefill_tokens": 65536
}
]
}
@@ -0,0 +1,794 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! HTTP contract for static P/D buckets.
//!
//! Buckets narrow the candidate domain before policy selection. Prefill SLO
//! profiles may override rank, while decode uses `input_tokens + max_tokens`.
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,
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::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tower::ServiceExt;
fn bucket(id: &str, stage: BucketStage, rank: u32, worker_id: &str) -> BucketSpec {
BucketSpec {
id: id.into(),
stage,
rank,
worker_ids: vec![worker_id.into()],
min_extend_tokens: None,
max_extend_tokens: None,
min_sequence_tokens: None,
max_sequence_tokens: None,
max_context_tokens: Some(16_384),
ttft_p95_at_capacity_ms: None,
tps_p05_at_capacity: None,
max_pending_prefill_tokens: None,
}
}
fn build_app_context(
specs: Vec<WorkerSpec>,
bucket_config: BucketConfig,
policy: PolicyKind,
affinity: Option<AffinityConfig>,
) -> AppContext {
let config = Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy,
decode_policy: Default::default(),
bucket_config: Some(bucket_config),
circuit_breaker: None,
cache_aware: None,
sticky: None,
affinity,
fused: None,
eligibility: None,
},
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&config).unwrap());
let registry = Arc::new(WorkerRegistry::default());
for spec in specs {
let _ = registry.add(spec);
}
let policies = Arc::new(build_registry_with_defaults(&config).unwrap());
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
AppContext::new(config, tokenizers, proxy, registry, policies)
}
fn build_ctx(
specs: Vec<WorkerSpec>,
bucket_config: BucketConfig,
policy: PolicyKind,
affinity: Option<AffinityConfig>,
) -> Arc<AppContext> {
Arc::new(build_app_context(specs, bucket_config, policy, affinity))
}
struct FakePrefixIndex {
address: Option<String>,
calls: AtomicUsize,
}
impl FakePrefixIndex {
fn matched(address: String) -> Arc<Self> {
Arc::new(Self {
address: Some(address),
calls: AtomicUsize::new(0),
})
}
fn no_signal() -> Arc<Self> {
Arc::new(Self {
address: None,
calls: AtomicUsize::new(0),
})
}
}
#[tonic::async_trait]
impl PrefixIndex for FakePrefixIndex {
async fn match_prefix(&self, hashes: Vec<i64>) -> Result<PrefixOutcome, PrefixIndexError> {
self.calls.fetch_add(1, Ordering::Relaxed);
let Some(address) = &self.address else {
return Ok(PrefixOutcome::Empty);
};
let matched_prefix_blocks =
u32::try_from(hashes.len().saturating_sub(1)).unwrap_or(u32::MAX);
Ok(PrefixOutcome::Matched {
matches: vec![PrefixMatch {
address: address.clone(),
matched_prefix_blocks,
worker_id: "fake-index-worker".into(),
}],
best_prefix_blocks: matched_prefix_blocks,
})
}
}
struct TwoPrefixIndex {
best_address: String,
lower_ranked_address: String,
}
impl TwoPrefixIndex {
fn new(best_address: String, lower_ranked_address: String) -> Arc<Self> {
Arc::new(Self {
best_address,
lower_ranked_address,
})
}
}
#[tonic::async_trait]
impl PrefixIndex for TwoPrefixIndex {
async fn match_prefix(&self, hashes: Vec<i64>) -> Result<PrefixOutcome, PrefixIndexError> {
let best_prefix_blocks = u32::try_from(hashes.len().saturating_sub(1)).unwrap_or(u32::MAX);
let lower_ranked_prefix_blocks = (best_prefix_blocks / 2).max(1);
Ok(PrefixOutcome::Matched {
matches: vec![
PrefixMatch {
address: self.best_address.clone(),
matched_prefix_blocks: best_prefix_blocks,
worker_id: "best-index-worker".into(),
},
PrefixMatch {
address: self.lower_ranked_address.clone(),
matched_prefix_blocks: lower_ranked_prefix_blocks,
worker_id: "lower-index-worker".into(),
},
],
best_prefix_blocks,
})
}
}
fn build_cache_ctx(
specs: Vec<WorkerSpec>,
bucket_config: BucketConfig,
prefix_index: Arc<dyn PrefixIndex>,
) -> Arc<AppContext> {
build_cache_ctx_with_affinity(
specs,
bucket_config,
prefix_index,
AffinityConfig::default(),
)
}
fn build_cache_ctx_with_affinity(
specs: Vec<WorkerSpec>,
bucket_config: BucketConfig,
prefix_index: Arc<dyn PrefixIndex>,
affinity: AffinityConfig,
) -> Arc<AppContext> {
let mut context =
build_app_context(specs, bucket_config, PolicyKind::CacheAware, Some(affinity));
context.config.model.cache_aware = Some(CacheAwareConfig {
prefix_provider: CachePrefixProvider::Indexer,
kv_indexer_endpoint: Some(KvIndexerEndpointConfig {
url: "http://fake-indexer".into(),
query_timeout_ms: 100,
query_max_inflight: 32,
}),
});
context.prefix_index = Some(prefix_index);
context.block_size_oracle.try_set(1).unwrap();
Arc::new(context)
}
fn worker_spec(id: &str, url: String, mode: WorkerMode) -> WorkerSpec {
WorkerSpec {
id: WorkerId(id.into()),
url,
mode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: (mode == WorkerMode::Prefill).then_some(8997),
}
}
fn set_native_load(
ctx: &AppContext,
worker_url: &str,
num_total_tokens: u64,
max_total_num_tokens: u64,
) {
ctx.engine_load.set(
worker_url,
0,
LoadStat {
num_running_reqs: 0,
num_waiting_reqs: 0,
num_tokens: num_total_tokens,
max_total_num_tokens,
native_cache: Some(NativeCacheRankLoad {
num_waiting_uncached_tokens: 0,
num_total_tokens,
max_running_requests: 64,
total_prefill_uncached_tokens: 1,
total_prefill_busy_us: 1,
}),
},
Instant::now(),
);
}
fn chat_request(ttft_slo_ms: Option<u64>, max_tokens: Option<u64>) -> Request<Body> {
chat_request_with_content("bucket routing", ttft_slo_ms, max_tokens, None)
}
fn chat_request_with_content(
content: &str,
ttft_slo_ms: Option<u64>,
max_tokens: Option<u64>,
session_id: Option<&str>,
) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json");
if let Some(ttft_slo_ms) = ttft_slo_ms {
builder = builder.header("x-sgl-ttft-slo-ms", ttft_slo_ms.to_string());
}
if let Some(session_id) = session_id {
builder = builder.header("x-session-id", session_id);
}
builder
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": content}],
"max_tokens": max_tokens,
}))
.unwrap(),
))
.unwrap()
}
async fn wait_for_prefill(mock: &crate::common::mock_worker::MockWorker) {
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if mock.captured.lock().unwrap().last_body.is_some() {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("selected prefill worker must receive the detached request");
}
async fn wait_for_prefill_body_containing(
mock: &crate::common::mock_worker::MockWorker,
expected: &str,
) -> Vec<u8> {
tokio::time::timeout(Duration::from_secs(2), async {
loop {
let captured = mock.captured.lock().unwrap().last_body.clone();
if let Some(body) = captured {
if String::from_utf8_lossy(&body).contains(expected) {
return body.to_vec();
}
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("selected prefill worker must receive the expected request body")
}
#[tokio::test]
async fn prefill_slo_first_uses_eligible_ttft_bucket_before_lower_rank_bucket() {
let cheap = crate::common::mock_worker::MockWorker::start(vec![]).await;
let fast = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let mut cheap_bucket = bucket("p-cheap", BucketStage::Prefill, 10, "p-cheap");
cheap_bucket.ttft_p95_at_capacity_ms = Some(400);
let mut fast_bucket = bucket("p-fast", BucketStage::Prefill, 20, "p-fast");
fast_bucket.ttft_p95_at_capacity_ms = Some(100);
let bucket_config = BucketConfig {
buckets: vec![
cheap_bucket,
fast_bucket,
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
],
ttft_slo_policy: SloBucketPolicy::SloFirst,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let ctx = build_ctx(
vec![
worker_spec("p-cheap", cheap.url.clone(), WorkerMode::Prefill),
worker_spec("p-fast", fast.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
PolicyKind::PowerOfTwo,
None,
);
let response = build_router(ctx)
.oneshot(chat_request(Some(200), Some(16)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
wait_for_prefill(&fast).await;
assert!(
cheap.captured.lock().unwrap().last_body.is_none(),
"lower-rank but TTFT-ineligible P Bucket must not be dispatched first"
);
}
#[tokio::test]
async fn prefill_tries_later_compatible_bucket_before_capacity_fallback() {
let full = crate::common::mock_worker::MockWorker::start(vec![]).await;
let available = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let bucket_config = BucketConfig {
buckets: vec![
bucket("p-full", BucketStage::Prefill, 10, "p-full"),
bucket("p-available", BucketStage::Prefill, 20, "p-available"),
bucket("d", BucketStage::Decode, 30, "d"),
],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let ctx = build_ctx(
vec![
worker_spec("p-full", full.url.clone(), WorkerMode::Prefill),
worker_spec("p-available", available.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
PolicyKind::PowerOfTwo,
None,
);
set_native_load(&ctx, &full.url, 100, 100);
set_native_load(&ctx, &available.url, 0, 10_000);
let response = build_router(ctx)
.oneshot(chat_request(None, Some(16)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
wait_for_prefill(&available).await;
assert!(
full.captured.lock().unwrap().last_body.is_none(),
"capacity fallback must wait until all compatible prefill buckets are exhausted"
);
}
#[tokio::test]
async fn decode_bucket_uses_input_plus_requested_output_budget() {
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let short_decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let long_decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let mut short_bucket = bucket("d-short", BucketStage::Decode, 20, "d-short");
short_bucket.max_sequence_tokens = Some(1_024);
let mut long_bucket = bucket("d-long", BucketStage::Decode, 30, "d-long");
long_bucket.min_sequence_tokens = Some(1_025);
let bucket_config = BucketConfig {
buckets: vec![
bucket("p", BucketStage::Prefill, 10, "p"),
short_bucket,
long_bucket,
],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let ctx = build_ctx(
vec![
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
worker_spec("d-short", short_decode.url.clone(), WorkerMode::Decode),
worker_spec("d-long", long_decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
PolicyKind::PowerOfTwo,
None,
);
let response = build_router(ctx)
.oneshot(chat_request(None, Some(2_000)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-sgl-decode-url")
.and_then(|value| value.to_str().ok()),
Some(long_decode.url.as_str()),
"peak sequence length must exclude the short Decode Bucket"
);
assert!(
long_decode.captured.lock().unwrap().last_body.is_some(),
"the selected long Decode worker is awaited before the response"
);
assert!(
short_decode.captured.lock().unwrap().last_body.is_none(),
"the incompatible short Decode Bucket must not receive the request"
);
}
#[tokio::test]
async fn decode_tries_later_compatible_bucket_before_capacity_fallback() {
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let full = crate::common::mock_worker::MockWorker::start(vec![]).await;
let available = crate::common::mock_worker::MockWorker::start(vec![]).await;
let bucket_config = BucketConfig {
buckets: vec![
bucket("p", BucketStage::Prefill, 10, "p"),
bucket("d-full", BucketStage::Decode, 20, "d-full"),
bucket("d-available", BucketStage::Decode, 30, "d-available"),
],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let ctx = build_ctx(
vec![
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
worker_spec("d-full", full.url.clone(), WorkerMode::Decode),
worker_spec("d-available", available.url.clone(), WorkerMode::Decode),
],
bucket_config,
PolicyKind::PowerOfTwo,
None,
);
set_native_load(&ctx, &full.url, 100, 100);
set_native_load(&ctx, &available.url, 0, 10_000);
let response = build_router(ctx)
.oneshot(chat_request(None, Some(16)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-sgl-decode-url")
.and_then(|value| value.to_str().ok()),
Some(available.url.as_str()),
"capacity fallback must wait until all compatible decode buckets are exhausted"
);
}
#[tokio::test]
async fn prefill_only_bucket_configuration_keeps_global_decode_routing() {
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let bucket_config = BucketConfig {
buckets: vec![bucket("p", BucketStage::Prefill, 10, "p")],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let ctx = build_ctx(
vec![
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
PolicyKind::PowerOfTwo,
None,
);
let response = build_router(ctx)
.oneshot(chat_request(None, Some(16)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-sgl-decode-url")
.and_then(|value| value.to_str().ok()),
Some(decode.url.as_str()),
"a Prefill-only Bucket rollout must retain the Step 1 global Decode domain"
);
}
#[tokio::test]
async fn global_rebind_session_affinity_can_keep_a_cross_length_bucket_primary() {
let short = crate::common::mock_worker::MockWorker::start(vec![]).await;
let long = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, "p-short");
short_bucket.max_extend_tokens = Some(256);
short_bucket.max_context_tokens = Some(16_384);
short_bucket.ttft_p95_at_capacity_ms = Some(80);
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, "p-long");
long_bucket.min_extend_tokens = Some(257);
long_bucket.max_context_tokens = Some(16_384);
long_bucket.ttft_p95_at_capacity_ms = Some(300);
let bucket_config = BucketConfig {
buckets: vec![
short_bucket,
long_bucket,
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
],
ttft_slo_policy: SloBucketPolicy::SloFirst,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let ctx = build_ctx(
vec![
worker_spec("p-short", short.url.clone(), WorkerMode::Prefill),
worker_spec("p-long", long.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
PolicyKind::SessionAware,
Some(AffinityConfig {
session_affinity_mode: SessionAffinityMode::GlobalRebind,
..Default::default()
}),
);
let app = build_router(ctx);
let first = app
.clone()
.oneshot(chat_request_with_content(
"short",
Some(120),
Some(8),
Some("s-1"),
))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
wait_for_prefill(&short).await;
let long_content = "length ".repeat(128);
let second = app
.oneshot(chat_request_with_content(
&long_content,
Some(120),
Some(8),
Some("s-1"),
))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::OK);
let short_body = wait_for_prefill_body_containing(&short, &long_content).await;
assert!(
String::from_utf8_lossy(&short_body).contains(&long_content),
"the second, long request must retain the existing cross-Bucket session primary"
);
assert!(
long.captured.lock().unwrap().last_body.is_none(),
"target length Bucket is skipped only because the primary's own Hard TTFT profile is eligible"
);
}
#[tokio::test]
async fn global_preserve_establishes_then_reuses_a_new_assignment() {
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let bucket_config = BucketConfig {
buckets: vec![
bucket("p", BucketStage::Prefill, 10, "p"),
bucket("d", BucketStage::Decode, 20, "d"),
],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let ctx = build_ctx(
vec![
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
PolicyKind::SessionAware,
Some(AffinityConfig {
session_affinity_mode: SessionAffinityMode::GlobalPreserve,
..Default::default()
}),
);
let app = build_router(Arc::clone(&ctx));
for content in ["first global request", "second global request"] {
let response = app
.clone()
.oneshot(chat_request_with_content(
content,
None,
Some(8),
Some("global-session"),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let metrics = ctx.metrics.render();
assert!(
metrics.contains(
r#"sgl_router_policy_decisions_total{policy="session_aware",reason="assigned"} 1"#
),
"the first global-preserve request must establish an assignment: {metrics}"
);
assert!(
metrics.contains(
r#"sgl_router_policy_decisions_total{policy="session_aware",reason="session_primary"} 1"#
),
"the second global-preserve request must reuse the assignment: {metrics}"
);
}
#[tokio::test]
async fn cache_winner_uses_target_uncached_work_before_prompt_length_bucket() {
let short = crate::common::mock_worker::MockWorker::start(vec![]).await;
let long = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, "p-short");
short_bucket.max_extend_tokens = Some(8);
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, "p-long");
long_bucket.min_extend_tokens = Some(9);
let bucket_config = BucketConfig {
buckets: vec![
short_bucket,
long_bucket,
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let index = FakePrefixIndex::matched(short.url.clone());
let prefix_index: Arc<dyn PrefixIndex> = index.clone();
let ctx = build_cache_ctx(
vec![
worker_spec("p-short", short.url.clone(), WorkerMode::Prefill),
worker_spec("p-long", long.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
prefix_index,
);
let content = "cached-prefix ".repeat(128);
let response = build_router(ctx)
.oneshot(chat_request_with_content(&content, None, Some(8), None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
wait_for_prefill(&short).await;
assert!(
long.captured.lock().unwrap().last_body.is_none(),
"a cache winner with small target-specific uncached work must not be replaced by the full-length Bucket"
);
assert_eq!(
index.calls.load(Ordering::Relaxed),
1,
"the async Indexer query must run once at ingress, not once per Bucket"
);
}
#[tokio::test]
async fn cache_candidate_bucket_binding_happens_before_candidate_limit() {
let best = crate::common::mock_worker::MockWorker::start(vec![]).await;
let lower_ranked = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let mut best_bucket = bucket("p-best", BucketStage::Prefill, 10, "p-best");
best_bucket.min_extend_tokens = Some(32);
let mut lower_ranked_bucket = bucket("p-lower", BucketStage::Prefill, 20, "p-lower");
lower_ranked_bucket.min_extend_tokens = Some(32);
let bucket_config = BucketConfig {
buckets: vec![
best_bucket,
lower_ranked_bucket,
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let index: Arc<dyn PrefixIndex> =
TwoPrefixIndex::new(best.url.clone(), lower_ranked.url.clone());
let ctx = build_cache_ctx_with_affinity(
vec![
worker_spec("p-best", best.url.clone(), WorkerMode::Prefill),
worker_spec("p-lower", lower_ranked.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
index,
AffinityConfig {
cache_candidate_min_workers: 1,
cache_candidate_ratio: 0.0,
cache_candidate_max_workers: 1,
..AffinityConfig::default()
},
);
let content = "cached bucket candidate ".repeat(256);
let response = build_router(Arc::clone(&ctx))
.oneshot(chat_request_with_content(&content, None, Some(8), None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
wait_for_prefill(&lower_ranked).await;
assert!(
best.captured.lock().unwrap().last_body.is_none(),
"the top Indexer hit is Bucket-incompatible and must not consume K=1"
);
assert!(
ctx.metrics.render().contains(
r#"sgl_router_policy_decisions_total{policy="cache_aware",reason="cache_candidate"} 1"#
),
"the compatible lower-ranked cache holder must remain a cache candidate"
);
}
#[tokio::test]
async fn cache_no_signal_restarts_normal_prompt_length_bucket_fallback() {
let short = crate::common::mock_worker::MockWorker::start(vec![]).await;
let long = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, "p-short");
short_bucket.max_extend_tokens = Some(8);
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, "p-long");
long_bucket.min_extend_tokens = Some(9);
let bucket_config = BucketConfig {
buckets: vec![
short_bucket,
long_bucket,
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
],
ttft_slo_policy: SloBucketPolicy::Disabled,
tps_slo_policy: SloBucketPolicy::Disabled,
};
let index = FakePrefixIndex::no_signal();
let prefix_index: Arc<dyn PrefixIndex> = index.clone();
let ctx = build_cache_ctx(
vec![
worker_spec("p-short", short.url.clone(), WorkerMode::Prefill),
worker_spec("p-long", long.url.clone(), WorkerMode::Prefill),
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
],
bucket_config,
prefix_index,
);
let content = "uncached-prompt ".repeat(128);
let response = build_router(ctx)
.oneshot(chat_request_with_content(&content, None, Some(8), None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
wait_for_prefill(&long).await;
assert!(
short.captured.lock().unwrap().last_body.is_none(),
"without a cache winner the request must restart the normal full-input Bucket path"
);
assert_eq!(index.calls.load(Ordering::Relaxed), 1);
}
@@ -12,20 +12,11 @@
//! doesn't render tool schemas, so its ids would diverge from the engine).
//! * A request with multimodal (array) content → `input_ids` omitted (a text
//! tokenizer can't represent image content).
//!
//! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches
//! the built-in V4 chat encoder — the engine-equivalent path — without a
//! template fixture.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use sgl_router::config::{
ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::engine_load::EngineLoadTable;
use sgl_router::policies::factory::build_registry;
use sgl_router::policies::kv_events::{BlockSizeOracle, HashTree};
use sgl_router::proxy::Proxy;
@@ -37,36 +28,9 @@ use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
use crate::common::cache_aware_fixture::{config, MODEL};
use crate::common::mock_worker::MockWorker;
const MODEL: &str = "deepseek-v4-tiny";
fn config() -> Config {
Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
model: ModelConfig {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::CacheAwareZmq,
circuit_breaker: None,
cache_aware: Some(CacheAwareConfig::default()),
sticky: None,
affinity: None,
fused: None,
eligibility: None,
},
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
}
}
fn build_ctx(url: String) -> Arc<AppContext> {
let cfg = config();
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
@@ -82,18 +46,9 @@ fn build_ctx(url: String) -> Arc<AppContext> {
model_ids: vec![ModelId(MODEL.into())],
bootstrap_port: None,
});
// Use the real loaded tokenizers (not the empty-registry test default) so
// the cache-aware policy can tokenize at ingress.
let policies = Arc::new(
build_registry(
&cfg,
Arc::new(HashTree::new()),
Arc::clone(&tokenizers),
BlockSizeOracle::new(),
EngineLoadTable::new(),
)
.unwrap(),
);
// Use the configured tokenizer so the chat path can emit input_ids.
let policies =
Arc::new(build_registry(&cfg, Arc::new(HashTree::new()), BlockSizeOracle::new()).unwrap());
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
}
@@ -34,6 +34,8 @@ fn config_for(_worker_url: &str) -> Config {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -13,7 +13,7 @@ use sgl_router::config::{
pub const MODEL: &str = "deepseek-v4-tiny";
/// A single-model `cache_aware_zmq` router. Discovery is a placeholder because
/// A single-model native `cache_aware` router. Discovery is a placeholder because
/// every caller installs its own `WorkerRegistry`.
pub fn config() -> Config {
Config {
@@ -25,11 +25,13 @@ pub fn config() -> Config {
model: ModelConfig {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::CacheAwareZmq,
policy: PolicyKind::CacheAware,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: Some(CacheAwareConfig::default()),
sticky: None,
affinity: None,
sticky: None,
fused: None,
eligibility: None,
},
@@ -16,8 +16,8 @@ use sgl_kv_indexer::pb::{
use sgl_kv_indexer::{
server_builder, GrpcPrefixIndex, InMemoryKvIndexerBackend, KvIndexerService, PrefixIndexConfig,
};
use sgl_router::config::{AffinityConfig, CachePrefixProvider, PolicyKind};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::engine_load::EngineLoadTable;
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;
@@ -36,7 +36,20 @@ use crate::common::mock_worker::MockWorker;
async fn external_indexer_routes_to_the_cached_worker() {
let cached = MockWorker::start(vec![]).await;
let uncached = MockWorker::start(vec![]).await;
let cfg = config();
let mut cfg = config();
cfg.model.policy = PolicyKind::CacheAware;
cfg.model
.cache_aware
.as_mut()
.expect("fixture includes cache-aware configuration")
.prefix_provider = CachePrefixProvider::Indexer;
cfg.model.affinity = Some(AffinityConfig {
cache_affinity_min_matched_tokens: Some(0),
cache_candidate_min_workers: 1,
cache_candidate_ratio: 1.0,
cache_candidate_max_workers: 1,
..Default::default()
});
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let body = json!({
"model": MODEL,
@@ -68,6 +81,7 @@ async fn external_indexer_routes_to_the_cached_worker() {
hashes: hashes.clone(),
component_masks: Vec::new(),
block_sizes: Vec::new(),
parent_block_hash: None,
}],
worker_address: cached.url.clone(),
cache_spec: None,
@@ -89,16 +103,8 @@ async fn external_indexer_routes_to_the_cached_worker() {
}
let oracle = BlockSizeOracle::new();
oracle.try_set(1).unwrap();
let policies = Arc::new(
build_registry(
&cfg,
Arc::new(HashTree::new()),
Arc::clone(&tokenizers),
Arc::clone(&oracle),
EngineLoadTable::new(),
)
.unwrap(),
);
let policies =
Arc::new(build_registry(&cfg, Arc::new(HashTree::new()), Arc::clone(&oracle)).unwrap());
let mut ctx = AppContext::new(
cfg,
tokenizers,
@@ -35,6 +35,8 @@ async fn failover_when_one_worker_dies() {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: Some(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(1).unwrap(), // open after first failure
cool_down_secs: 30,
@@ -14,7 +14,7 @@
//! guards, or in the SSE pump's `tx.send().await` race — all of which
//! would be silently skipped by a synthetic-handler test.
use bytes::Bytes;
use futures::future::join_all;
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
@@ -44,6 +44,8 @@ fn build_ctx_with_worker(worker_url: &str) -> Arc<AppContext> {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -130,12 +132,11 @@ async fn shutdown_drains_100_inflight_streaming_chat_completions() {
}))
.unwrap();
let mut handles = Vec::with_capacity(N);
for i in 0..N {
let responses = join_all((0..N).map(|i| {
let c = client.clone();
let u = url.clone();
let b = body.clone();
handles.push(tokio::spawn(async move {
async move {
let resp = c
.post(&u)
.header("content-type", "application/json")
@@ -146,33 +147,34 @@ async fn shutdown_drains_100_inflight_streaming_chat_completions() {
if !resp.status().is_success() {
return Err(format!("client {i} non-2xx: {}", resp.status()));
}
let bytes: Bytes = resp
.bytes()
.await
.map_err(|e| format!("client {i} body: {e}"))?;
Ok::<Bytes, String>(bytes)
}));
}
Ok::<_, String>((i, resp))
}
}))
.await;
let responses: Vec<_> = responses
.into_iter()
.collect::<Result<_, _>>()
.expect("every client received response headers before shutdown");
// 4. Let every request grab a connection and start receiving data.
// 100 ms is past the first chunk delay (60 ms) for every stream
// but well before the last chunk fires.
tokio::time::sleep(Duration::from_millis(100)).await;
// 5. Trigger shutdown. axum stops accepting new connections but
// MUST drain the 100 already-attached streams.
// 4. Each response header confirms that its request is in flight. Trigger
// shutdown only after the full cohort connects, then verify that Axum
// drains all 100 existing streams.
let started = Instant::now();
shutdown_tx.send(()).unwrap();
// 6. Every in-flight request must complete with a `[DONE]` terminator
// 5. Every in-flight request must complete with a `[DONE]` terminator
// — proving the stream was NOT truncated by shutdown.
let mut bytes_total: usize = 0;
let mut done_count: usize = 0;
for h in handles {
let result = h
for result in join_all(responses.into_iter().map(|(i, response)| async move {
response
.bytes()
.await
.expect("client task panicked")
.expect("client completed");
.map_err(|e| format!("client {i} body: {e}"))
}))
.await
{
let result = result.expect("client body completed");
bytes_total += result.len();
let body_str = String::from_utf8_lossy(&result);
if body_str.contains("data: [DONE]") {
@@ -31,6 +31,8 @@ async fn forwards_whitelisted_headers_strips_others() {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -10,6 +10,7 @@
mod common;
mod bucket_routing;
mod cache_aware_input_ids;
mod chat_routing;
mod external_indexer_routing;
@@ -18,6 +19,7 @@ mod graceful_shutdown;
mod header_forwarding;
mod pd_bootstrap_injection;
mod pd_pool_isolation;
mod radix_tree_routing;
mod roundrobin_input_ids;
mod shared_prefill_admission;
mod sticky_input_ids;
@@ -32,7 +32,7 @@ use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tower::ServiceExt;
fn config() -> Config {
@@ -46,6 +46,8 @@ fn config() -> Config {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -98,7 +100,7 @@ async fn await_captured_body(
timeout: Duration,
label: &str,
) -> Bytes {
let start = std::time::Instant::now();
let start = Instant::now();
loop {
// Release the `std::sync::Mutex` guard before the sleep.await
// (clippy: await_holding_lock).
@@ -189,6 +191,43 @@ async fn pd_mode_chat_injects_bootstrap_fields_into_both_bodies() {
assert_eq!(bootstrap_port(&dj), Some(8997));
}
#[tokio::test]
async fn round_robin_pd_prefill_does_not_track_dispatch_timestamps() {
let prefill =
crate::common::mock_worker::MockWorker::start_hanging(Duration::from_millis(200)).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("p1".into()),
url: prefill.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(8997),
},
WorkerSpec {
id: WorkerId("d1".into()),
url: decode.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
]);
let prefill_worker = ctx
.registry
.workers_for(&ModelId("tiny".into()))
.into_iter()
.find(|worker| worker.id.0 == "p1")
.expect("prefill worker is registered");
let cutoff = Instant::now() - Duration::from_secs(1);
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.slots_acquired_since(cutoff), 0);
assert_eq!(request.await.unwrap().unwrap().status(), StatusCode::OK);
}
/// Plain-mode (non-PD) requests do NOT carry any `bootstrap_*` field.
/// The injection step is gated on `worker.mode() == Prefill`; plain
/// workers serve the chat route directly without disagg bootstrapping.
@@ -45,6 +45,8 @@ fn config() -> Config {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -205,31 +207,19 @@ async fn pd_mode_chat_dispatch_fans_to_both_prefill_and_decode() {
assert!(!prefill_body.is_empty());
}
/// Task C: PD-mode chat request carries an `x-sgl-decode-url` header
/// pointing at the host-affinity decode peer. With two prefill workers
/// on different hosts and a decode worker on each, the affinity helper
/// MUST pick the decode peer co-located with the chosen prefill.
///
/// Round-robin will select prefill workers deterministically (alphabetic
/// dashmap order is not guaranteed; the test fires several requests so
/// at least one lands on each prefill, and asserts the per-host pairing
/// holds across all of them).
/// PD-mode chat request carries an `x-sgl-decode-url` header for the final
/// Decode decision. Step 1 defaults to Decode P2; the header remains an
/// observability contract regardless of which Decode policy produced it.
#[tokio::test]
async fn pd_mode_chat_dispatch_sets_decode_affinity_header() {
async fn pd_mode_chat_dispatch_sets_final_decode_header() {
use std::collections::HashSet;
let prefill_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
let prefill_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
// MockWorker URLs always bind to `127.0.0.1`, so every worker
// shares the same host string and the affinity helper's
// same-host branch is moot here — the helper still returns a
// decode peer via the load-tiebreak fallback. The unit tests in
// `policies::registry::tests::decoder_picks_same_host_when_available`
// carry the real burden of pinning the host-affinity rules; this
// integration test only asserts the wiring is in place (the
// `x-sgl-decode-url` header IS set on PD requests, and the
// value is one of the registered decode worker URLs).
// MockWorker URLs all bind to `127.0.0.1`; this test deliberately does
// not assert a host relation. It pins only the HTTP wiring: the final D
// selected by the role-local policy is reflected on the P request.
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("p1".into()),
@@ -268,9 +258,8 @@ async fn pd_mode_chat_dispatch_sets_decode_affinity_header() {
assert_eq!(res.status(), StatusCode::OK);
}
// Every request that hit a prefill mock MUST carry the decode-hint
// header. The header value MUST be one of the two registered
// decode worker URLs.
// Every request that hit a prefill mock MUST carry the final-decode
// header. The value MUST be one of the two registered Decode URLs.
let decode_urls: HashSet<String> = [decode_a.url.clone(), decode_b.url.clone()]
.into_iter()
.collect();
@@ -346,9 +335,9 @@ async fn pd_mode_prefill_only_returns_no_decode_workers_available() {
}
/// PD-mode chat response carries `x-sgl-decode-url` so external tests
/// can observe decode affinity end-to-end (without sniffing the proxy
/// can observe final Decode selection end-to-end (without sniffing the proxy
/// hop into the upstream prefill worker). Mirrors the request-side
/// behavior asserted by `pd_mode_chat_dispatch_sets_decode_affinity_header`.
/// behavior asserted by `pd_mode_chat_dispatch_sets_final_decode_header`.
#[tokio::test]
async fn pd_mode_chat_response_carries_decode_affinity_header() {
use std::collections::HashSet;
@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
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::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use tower::ServiceExt;
use crate::common::cache_aware_fixture::{config, MODEL};
use crate::common::mock_worker::MockWorker;
#[tokio::test]
async fn radix_tree_routes_cache_aware_request_to_cached_worker() {
let cached = MockWorker::start(vec![]).await;
let uncached = MockWorker::start(vec![]).await;
let mut cfg = config();
cfg.model.policy = PolicyKind::CacheAware;
cfg.model.cache_aware.as_mut().unwrap().prefix_provider = CachePrefixProvider::RadixTree;
cfg.model.affinity = Some(AffinityConfig {
cache_affinity_min_matched_tokens: Some(0),
cache_candidate_min_workers: 1,
cache_candidate_ratio: 1.0,
cache_candidate_max_workers: 1,
..Default::default()
});
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let body = json!({
"model": MODEL,
"messages": [{"role": "user", "content": "local radix cache hit"}],
});
let tokens = request_tokens_for(&tokenizers, &ModelId(MODEL.into()), &body)
.expect("test prompt tokenizes");
let hashes = compute_block_hashes(&tokens.ids, 1);
assert!(!hashes.is_empty());
let tree = Arc::new(HashTree::new());
tree.insert(&KvWorkerId::new(cached.url.clone(), 0), None, &hashes);
let registry = Arc::new(WorkerRegistry::default());
for url in [&cached.url, &uncached.url] {
registry
.add(WorkerSpec {
id: WorkerId(url.clone()),
url: url.clone(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId(MODEL.into())],
bootstrap_port: None,
})
.unwrap();
}
let oracle = BlockSizeOracle::new();
oracle.try_set(1).unwrap();
let policies = Arc::new(build_registry(&cfg, Arc::clone(&tree), Arc::clone(&oracle)).unwrap());
let mut ctx = AppContext::new(
cfg,
tokenizers,
Arc::new(Proxy::new(Duration::from_secs(5)).unwrap()),
registry,
policies,
);
ctx.radix_tree_prefix_provider = Some(RadixTreePrefixProvider::new(tree, Arc::clone(&oracle)));
ctx.block_size_oracle = oracle;
let response = build_router(Arc::new(ctx))
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(cached.captured.lock().unwrap().last_body.is_some());
assert!(uncached.captured.lock().unwrap().last_body.is_none());
}
@@ -42,6 +42,8 @@ fn config() -> Config {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -11,7 +11,7 @@ use sgl_router::config::{
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::engine_load::LoadStat;
use sgl_router::policies::engine_load::{LoadStat, NativeCacheRankLoad};
use sgl_router::policies::{
CacheCandidate, CacheCandidateProposal, Policy, PolicyRegistry, PrefillProposal, ProposalKind,
SelectionContext, SelectionProposal,
@@ -128,6 +128,7 @@ impl Policy for CacheCandidatesPolicy {
max_pending_prefill_tokens: None,
}],
cache_switch_margin_tokens: 0,
..Default::default()
}))
}
@@ -147,6 +148,8 @@ fn config(policy: PolicyKind) -> Config {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
@@ -286,17 +289,30 @@ async fn chat_commits_the_admitted_prefill_backup() {
})
})
.await;
let now = Instant::now();
let native_load = |total_prefill_uncached_tokens, total_prefill_busy_us| LoadStat {
num_running_reqs: 1,
num_waiting_reqs: 0,
num_tokens: 100,
max_total_num_tokens: 100,
native_cache: Some(NativeCacheRankLoad {
num_waiting_uncached_tokens: 0,
num_total_tokens: 100,
max_running_requests: 16,
total_prefill_uncached_tokens,
total_prefill_busy_us,
}),
};
fixture.ctx.engine_load.set(
&fixture.workers[0].url,
0,
LoadStat {
num_running_reqs: 1,
num_waiting_reqs: 0,
num_tokens: 100,
max_total_num_tokens: 100,
},
Instant::now(),
native_load(1, 1),
now - Duration::from_secs(1),
);
fixture
.ctx
.engine_load
.set(&fixture.workers[0].url, 0, native_load(2, 2), now);
assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK);
assert!(fixture.backends[0]
@@ -339,6 +355,13 @@ async fn capacity_exhaustion_does_not_return_503() {
num_waiting_reqs: 0,
num_tokens: 100,
max_total_num_tokens: 100,
native_cache: Some(NativeCacheRankLoad {
num_waiting_uncached_tokens: 0,
num_total_tokens: 100,
max_running_requests: 16,
total_prefill_uncached_tokens: 1,
total_prefill_busy_us: 1,
}),
},
Instant::now(),
);
@@ -415,6 +438,13 @@ async fn chat_records_cache_candidates_exhausted() {
num_waiting_reqs: 0,
num_tokens: 100,
max_total_num_tokens: 100,
native_cache: Some(NativeCacheRankLoad {
num_waiting_uncached_tokens: 0,
num_total_tokens: 100,
max_running_requests: 16,
total_prefill_uncached_tokens: 1,
total_prefill_busy_us: 1,
}),
},
Instant::now(),
);
@@ -54,6 +54,8 @@ fn config() -> Config {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::Sticky,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
// Push eviction far out so the background sweeper never fires
@@ -42,6 +42,8 @@ fn build_sticky_ctx(header_name: &str, worker_urls: &[String]) -> Arc<AppContext
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::Sticky,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: Some(StickyConfig {
@@ -38,6 +38,8 @@ fn config(_worker_url: &str) -> Config {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,