sgl-router: experimental Rust HTTP router for SGLang worker pools (#25851)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kangyan-Zhou
2026-05-25 15:34:05 +08:00
committed by GitHub
co-authored by Claude Opus 4.7
parent aae04b1241
commit 6e8fe176be
131 changed files with 28623 additions and 55 deletions
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod static_urls;
@@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::config::StaticUrlsDiscoveryConfig;
use sgl_router::discovery::{DiscoveryEvent, WorkerMode};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
#[tokio::test]
async fn emits_one_added_per_url_with_plain_seed() {
let cfg = StaticUrlsDiscoveryConfig {
urls: vec!["http://x:30000".into(), "http://y:30000".into()],
};
let (tx, mut rx) = mpsc::channel(16);
let _h = sgl_router::discovery::static_urls::spawn(cfg, tx)
.await
.unwrap();
let mut seen = std::collections::HashSet::new();
for _ in 0..2 {
let event = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.unwrap()
.unwrap();
match event {
DiscoveryEvent::Added(spec) => {
// mode / model_ids / bootstrap_port are seeded as Plain/empty/None;
// the worker manager fills them from /server_info post-discovery.
assert_eq!(spec.mode, WorkerMode::Plain);
assert!(spec.model_ids.is_empty());
assert_eq!(spec.bootstrap_port, None);
// The URL doubles as the worker id — strings already have to
// be unique (rejected at config-load otherwise).
assert_eq!(spec.id.0, spec.url);
seen.insert(spec.url);
}
other => panic!("unexpected event: {other:?}"),
}
}
assert_eq!(
seen,
["http://x:30000".to_string(), "http://y:30000".to_string()].into(),
);
}
/// Single-URL list — the common dev deployment shape. The producer
/// emits exactly one event and then parks until the receiver is
/// dropped. Earlier versions exited as soon as fan-out completed,
/// which tripped `server::supervisor::supervise_critical_tasks` →
/// `mark_unready` → `/readyz` 503; the lib-side
/// `stays_alive_after_fanout_until_receiver_dropped` pins that
/// invariant in isolation, while this test pins the same contract
/// through the public `spawn` entry point used by the binary.
#[tokio::test]
async fn emits_one_event_and_parks_until_receiver_dropped() {
let cfg = StaticUrlsDiscoveryConfig {
urls: vec!["http://x:30000".into()],
};
let (tx, mut rx) = mpsc::channel(16);
let h = sgl_router::discovery::static_urls::spawn(cfg, tx)
.await
.unwrap();
let event = rx.recv().await.unwrap();
assert!(matches!(event, DiscoveryEvent::Added(_)));
assert!(rx.try_recv().is_err(), "exactly one event expected");
// Drop the receiver → producer's `tx.closed()` resolves → task
// exits cleanly.
drop(rx);
tokio::time::timeout(Duration::from_secs(2), h)
.await
.expect("static_urls task should exit after receiver is dropped")
.expect("join handle should not panic");
}
/// Spin up a fake worker that advertises
/// `disaggregation_mode = "prefill"` + `disaggregation_bootstrap_port`,
/// pipe it through `spawn_discovery` (StaticUrls backend) into
/// `manager::run_with_config`, and assert the worker lands in the
/// registry with `WorkerMode::Prefill` + the disclosed port.
///
/// This is the load-bearing end-to-end assertion for the refactor's
/// central claim — "prefill, decode, and plain workers can all appear
/// in the same `urls` list and end up classified correctly" — exercised
/// against the full discovery → introspect → registry pipeline rather
/// than just the in-isolation `register_one` unit test.
#[tokio::test]
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, DiscoveryConfig, ObservabilityConfig,
ProxyConfig, ServerConfig,
};
use sgl_router::discovery::{spawn_discovery, WorkerId};
use sgl_router::workers::{manager, WorkerRegistry};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
// Fake worker advertising a prefill role + bootstrap port.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let url = format!("http://127.0.0.1:{port}");
let app = Router::new().route(
"/server_info",
get(|| async {
Json(json!({
"served_model_name": "tiny",
"disaggregation_mode": "prefill",
"disaggregation_bootstrap_port": 8998,
}))
}),
);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
let cfg = Config {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
models: vec![],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec![url.clone()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let registry = Arc::new(WorkerRegistry::default());
let (event_rx, _disc) = spawn_discovery(&cfg).await.unwrap();
let _mgr = tokio::spawn(manager::run_with_config(
event_rx,
registry.clone(),
Some(Arc::new(cfg)),
None,
None,
));
let id = WorkerId(url);
let resolved = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Some(w) = registry.get(&id) {
if w.mode() == WorkerMode::Prefill && w.bootstrap_port() == Some(8998) {
return true;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
resolved.is_ok(),
"expected mode=Prefill bootstrap_port=Some(8998); got {:?}",
registry.get(&id).map(|w| (w.mode(), w.bootstrap_port()))
);
let _ = shutdown_tx.send(());
}
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::health::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
use std::time::Duration;
fn cb() -> CircuitBreaker {
CircuitBreaker::with_config(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(3).unwrap(),
cool_down: Duration::from_millis(100),
})
}
#[test]
fn starts_closed_and_allows() {
let b = cb();
assert!(b.allow());
}
#[test]
fn three_failures_open_the_breaker() {
let b = cb();
b.record_failure();
b.record_failure();
assert!(b.allow(), "still closed before threshold");
b.record_failure();
assert!(!b.allow(), "open after threshold reached");
}
#[test]
fn intermittent_success_resets_failure_count() {
let b = cb();
b.record_failure();
b.record_failure();
b.record_success(); // resets
b.record_failure();
b.record_failure();
assert!(b.allow(), "should still be closed (2 failures since reset)");
}
#[tokio::test(start_paused = true)]
async fn open_breaker_recovers_via_half_open() {
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
assert!(!b.allow());
// Wait past cool_down.
tokio::time::advance(Duration::from_millis(150)).await;
// Half-open: allow one probe.
assert!(b.allow(), "half-open allows the probe");
// While half-open, further allow() calls should reject (only one probe in flight).
assert!(!b.allow(), "half-open rejects second probe");
// Probe succeeded.
b.record_success();
assert!(b.allow(), "closed after successful probe");
assert!(b.allow(), "stays closed");
}
#[tokio::test(start_paused = true)]
async fn half_open_failure_reopens() {
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
tokio::time::advance(Duration::from_millis(150)).await;
assert!(b.allow(), "half-open admit");
b.record_failure();
// Back to Open.
assert!(!b.allow(), "back to open");
}
#[tokio::test(start_paused = true)]
async fn would_allow_is_non_mutating_past_cool_down() {
// `would_allow()` answers "would `allow()` return true right now?" without
// claiming a probe slot. Enumeration / filtering paths (e.g.
// `WorkerRegistry::healthy_workers_for`) call it to inspect breakers
// without disturbing state.
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
assert!(!b.allow(), "open after threshold");
tokio::time::advance(Duration::from_millis(150)).await;
// Repeated would_allow() returns true and leaves state untouched.
assert!(b.would_allow());
assert!(b.would_allow());
assert!(b.would_allow());
// The first allow() claims the half-open probe.
assert!(b.allow(), "allow() admits the probe");
// The probe is in flight — subsequent allow() (and would_allow()) reject.
assert!(!b.allow(), "only one probe in flight");
assert!(!b.would_allow(), "would_allow() agrees: no slot available");
}
#[tokio::test(start_paused = true)]
async fn enumeration_then_dispatch_preserves_probe() {
// Regression for the bug where `healthy_workers_for` filtered with
// mutating `allow()`. Once would_allow() is the filter, an enumeration
// pass over many workers must not steal the probe slot from the one
// worker that actually gets dispatched to.
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
tokio::time::advance(Duration::from_millis(150)).await;
// Imagine 3 workers; enumeration filters each with would_allow().
for _ in 0..3 {
assert!(b.would_allow(), "filter sees the worker as available");
}
// Now the policy picks ONE worker and dispatch claims the probe.
assert!(b.allow(), "dispatch on the picked worker succeeds");
}
#[test]
fn would_allow_in_closed_state_is_true_and_non_mutating() {
let b = cb();
for _ in 0..5 {
assert!(b.would_allow());
}
// And allow() should still work afterwards.
assert!(b.allow());
}
#[tokio::test(start_paused = true)]
async fn open_breaker_recovery_is_not_delayed_by_continued_failures() {
// Regression: previously, record_failure on an already-Open breaker
// refreshed opened_at, so a failure storm pinned the breaker open
// forever. Now the cool_down is measured from first-open.
let b = CircuitBreaker::with_config(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(3).unwrap(),
cool_down: Duration::from_millis(100),
});
// Open it.
b.record_failure();
b.record_failure();
b.record_failure();
assert!(!b.allow(), "breaker should be open");
// Advance halfway through cool_down, then record more failures.
tokio::time::advance(Duration::from_millis(50)).await;
b.record_failure();
b.record_failure();
b.record_failure();
// Advance just past the original cool_down.
tokio::time::advance(Duration::from_millis(60)).await;
// We're past the original cool_down → HalfOpen.
assert!(
b.allow(),
"breaker should be half-open after cool_down from first-open"
);
}
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod circuit_breaker;
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Component-scope integration tests.
//!
//! Each submodule exercises a single library component (policy, registry,
//! discovery, health, tokenizer) via the crate's public API. None of these
//! tests spin up the full HTTP router; for those see `tests/proxy/`.
mod discovery;
mod health;
mod policies;
mod tokenizer;
mod workers;
@@ -0,0 +1,182 @@
// 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::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(),
models: vec![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,
}],
discovery: sgl_router::config::DiscoveryConfig {
backend: 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_index.tree(),
Arc::clone(&tokenizers),
block_size_oracle,
);
// 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,
};
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,75 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Cross-implementation parity test for the KV-event block-hash algorithm.
//!
//! The Rust implementation at `src/policies/kv_events/hash.rs` must produce
//! the same i64 block hashes as SGLang's `radix_cache::RadixKey.hash_page`
//! followed by `hash_str_to_int64`. Hard-coded `cross_language_golden_*`
//! values inside `hash.rs` are correct but brittle: if either side's
//! algorithm changes, the comments don't get regenerated and the tests
//! pass with stale expectations.
//!
//! This test consumes a fixture produced by
//! `tests/scripts/generate_kv_events_hash_parity.py`, which replicates the
//! SGLang algorithm verbatim (see the script's docstring for authority
//! pointers). CI regenerates the fixture (see
//! `.github/workflows/pr-test-sgl-router.yml`) and diffs against the
//! committed file; this test asserts the Rust implementation matches
//! whatever fixture is checked in.
use serde::Deserialize;
use sgl_router::policies::kv_events::compute_block_hashes;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
struct ParityCase {
name: String,
tokens: Vec<u32>,
block_size: usize,
expected_i64_hashes: Vec<i64>,
}
fn fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("kv_events_hash_parity.json")
}
fn load_cases() -> Vec<ParityCase> {
let path = fixture_path();
let bytes = std::fs::read(&path)
.unwrap_or_else(|e| panic!("read parity fixture {}: {e}", path.display()));
serde_json::from_slice(&bytes)
.unwrap_or_else(|e| panic!("decode parity fixture {}: {e}", path.display()))
}
#[test]
fn fixture_is_non_empty() {
let cases = load_cases();
assert!(
!cases.is_empty(),
"kv_events_hash_parity.json is empty — run \
tests/scripts/generate_kv_events_hash_parity.py",
);
}
/// Drives every case in the fixture through `compute_block_hashes` and
/// asserts equality with the Python-derived expectation.
#[test]
fn rust_block_hashes_match_python_radix_cache() {
for case in load_cases() {
// block_size of 0 is rejected by `compute_block_hashes` with a
// panic; the Python generator also rejects it. The fixture
// doesn't include a 0 case, so unwrap is safe.
let block_size = std::num::NonZeroUsize::new(case.block_size)
.unwrap_or_else(|| panic!("case {} has block_size=0 which is invalid", case.name));
let got = compute_block_hashes(&case.tokens, block_size.get());
assert_eq!(
got, case.expected_i64_hashes,
"case {}: tokens={:?} block_size={} — Rust produced {:?}, fixture says {:?}",
case.name, case.tokens, case.block_size, got, case.expected_i64_hashes,
);
}
}
@@ -0,0 +1,167 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Concurrent-mutation stress test for `HashTree`.
//!
//! The 19 inline tests in `policies::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
//! `match_prefix` from many concurrent requests.
//!
//! The tree is documented as taking a write-lock for mutations and a
//! read-lock for `match_prefix`; this test exercises that contract under
//! heavy contention to catch:
//!
//! * Deadlocks between the reverse index and the arena's RwLock.
//! * Logical races where a removed worker still appears in the reverse
//! index (or vice versa).
//! * Panics from a node arena being mutated mid-read.
//!
//! After the storm settles, the tree must be self-consistent: every
//! worker that was fully cleared must be absent from every node's worker
//! set, and `node_count()` must converge to zero.
use std::sync::Arc;
use std::thread;
use sgl_router::policies::kv_events::{HashTree, KvWorkerId};
fn worker(i: usize) -> KvWorkerId {
KvWorkerId {
url: format!("http://w{i}:30000"),
dp_rank: 0,
}
}
/// 8 mutator threads × 200 ops + 4 reader threads × 500 match queries.
/// Each mutator inserts a chain, queries it, then clears the worker; the
/// invariant is that after every thread joins, the tree is empty (every
/// worker was cleared) and no thread panicked.
#[test]
fn tree_survives_concurrent_inserts_removes_and_matches() {
let tree = Arc::new(HashTree::new());
let mut handles = Vec::new();
for tid in 0..8 {
let tree = tree.clone();
handles.push(thread::spawn(move || {
let w = worker(tid);
for round in 0..200_u64 {
// Each round uses a fresh chain so different mutators
// don't trample each other's nodes — we want contention
// on the lock, not contention on the keys (those are
// covered by the single-threaded reinsert/remove tests).
let chain: Vec<i64> = (0..4)
.map(|i| ((tid as i64) << 32) | ((round as i64) << 8) | i as i64)
.collect();
tree.insert(&w, None, &chain);
let m = tree.match_prefix(None, &chain);
assert!(
m.matched_blocks <= chain.len(),
"match must never exceed query length",
);
// Half the rounds use remove(&chain); the rest use
// clear_worker — both must leave a consistent tree.
if round % 2 == 0 {
tree.remove(&w, &chain);
} else {
tree.clear_worker(&w);
}
}
// Final blanket clear in case the last iteration used `remove`
// on only part of the chain.
tree.clear_worker(&w);
}));
}
for tid in 0..4 {
let tree = tree.clone();
handles.push(thread::spawn(move || {
for round in 0..500_u64 {
let probe: Vec<i64> = (0..3)
.map(|i| ((tid as i64) << 40) | ((round as i64) << 8) | i as i64)
.collect();
// Readers must never block-walk and must never panic.
let _ = tree.match_prefix(None, &probe);
}
}));
}
for h in handles {
h.join()
.expect("worker thread panicked under concurrent load");
}
assert_eq!(
tree.node_count(),
0,
"tree must be empty after every worker was cleared; \
residual nodes indicate a missed clear_worker path",
);
// The arena and the reverse index must agree: zero non-root nodes
// means zero `by_hash` entries. A bug that prunes the arena but not
// the reverse index would leak memory and corrupt future inserts;
// this assertion turns that into an immediate test failure.
assert_eq!(
tree.reverse_index_size(),
0,
"by_hash reverse index must be empty when no non-root nodes remain",
);
}
/// A mutator races `clear_worker` against a reader that is mid-`match_prefix`
/// on a deep chain. The reader must never see a partially-mutated tree
/// (no panic, no double-counted workers in the result set).
#[test]
fn match_prefix_is_consistent_with_concurrent_clear() {
let tree = Arc::new(HashTree::new());
let w = worker(0);
let chain: Vec<i64> = (0..32).map(|i| 1_000 + i).collect();
// Pre-populate so the reader has something to walk.
tree.insert(&w, None, &chain);
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mutator = {
let tree = tree.clone();
let stop = stop.clone();
let w = w.clone();
let chain = chain.clone();
thread::spawn(move || {
let mut round = 0u64;
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
if round.is_multiple_of(2) {
tree.clear_worker(&w);
} else {
tree.insert(&w, None, &chain);
}
round += 1;
}
})
};
for _ in 0..2_000 {
let m = tree.match_prefix(None, &chain);
// Either the worker was present (matched_blocks == chain.len(),
// workers set contains w) or it was cleared mid-walk (matched_blocks
// == 0 OR matched_blocks > 0 with empty workers if the chain is
// partially present). Whichever — the result must be internally
// consistent.
if m.matched_blocks == chain.len() {
assert!(
m.workers.contains(&w),
"full match must include worker; got {:?}",
m.workers,
);
}
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
mutator.join().unwrap();
}
@@ -0,0 +1,305 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Two independent `KvEventIndex` instances subscribed to the same PUB
//! socket — the in-process surrogate for "two router replicas watching
//! the same SGLang worker's KV publisher."
//!
//! Why this matters: sgl-router v1 explicitly omits multi-replica state
//! sync (deferred to v2 in the slim-design spec). Independent ZMQ
//! subscription is the **only** mechanism by which two routers arrive at
//! a consistent cache-aware view today. If a future change accidentally
//! degraded that property — e.g. a worker that only allows one subscriber,
//! a switch from PUB/SUB to PUSH/PULL, or a teardown bug that drops
//! events to one of N subscribers — this test fails loudly.
//!
//! Property pinned: after publishing N `BlockStored` events, both trees
//! report the same `match_prefix(matched_blocks, workers)` for the
//! published key, and an unpublished key remains absent from both.
use std::sync::Arc;
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 super::zmq_helpers::{
build_multipart, encode_block_stored_event, encode_event_batch, make_pub_bound,
};
#[tokio::test]
async fn two_independent_subscribers_converge_to_same_tree_state() {
// 1. One PUB socket — the worker. Both router surrogates connect to it.
let (mut publisher, port) = make_pub_bound().await;
let worker_url = "http://127.0.0.1:30000";
let block_size = 4u32;
let cfg = EventConfig {
host: "127.0.0.1".into(),
port_base: port,
topic: String::new(),
block_size,
dp_size: 1,
};
// 2. Two independent router-process surrogates, each with its own
// `KvEventIndex` (own tree, own subscriber, own pump task). Both
// call `add_worker` with the same preresolved `EventConfig` — the
// same shape production wires through `WorkerManager`.
let router_a = KvEventIndex::new();
let router_b = KvEventIndex::new();
router_a.add_worker(worker_url, Some(cfg.clone())).await;
router_b.add_worker(worker_url, Some(cfg.clone())).await;
// SUB-side handshake settle. Publishing before the subscribers
// finish their initial connect loses messages in PUB/SUB semantics;
// the polling loop below would then never converge.
tokio::time::sleep(Duration::from_millis(200)).await;
// 3. Publish a deterministic, multi-block event chain.
let tokens: Vec<u32> = (0..16).collect();
let hashes = compute_block_hashes(&tokens, block_size as usize);
assert!(
hashes.len() >= 3,
"test needs ≥3 blocks; got {}",
hashes.len()
);
let event_bytes = encode_block_stored_event(&hashes, None, &tokens, block_size);
let payload = encode_event_batch(0.0, vec![event_bytes], Some(0));
publisher
.send(build_multipart(1, payload))
.await
.expect("publish BlockStored");
// 4. Poll both trees until both report the FULL chain matched. The
// SUB→mpsc→pump→tree pipeline is async; loopback delivery is
// reliable but not instantaneous.
let target = hashes.len();
let key = KvWorkerId {
url: worker_url.into(),
dp_rank: 0,
};
let start = std::time::Instant::now();
loop {
let ma = router_a.tree().match_prefix(None, &hashes);
let mb = router_b.tree().match_prefix(None, &hashes);
let converged = ma.matched_blocks == target
&& mb.matched_blocks == target
&& ma.workers.contains(&key)
&& 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
// pick a worker; both routers picking the same key here
// means they would route the same prompt to the same worker.
assert_eq!(
ma.matched_blocks, mb.matched_blocks,
"subscribers disagreed on matched_blocks",
);
assert_eq!(
ma.workers, mb.workers,
"subscribers disagreed on worker set",
);
break;
}
if start.elapsed() > Duration::from_secs(3) {
panic!(
"subscribers did not converge within 3s: \
router_a={{matched={}, workers={:?}}}, \
router_b={{matched={}, workers={:?}}}, target={target}",
ma.matched_blocks, ma.workers, mb.matched_blocks, mb.workers,
);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
// 5. Negative leg: a key that was never published must not appear in
// either tree. Guards against a future bug where one subscriber
// accidentally inherits another's state (shared static, etc.).
let unseen: Vec<i64> = vec![999_999_999_001, 999_999_999_002, 999_999_999_003];
let na = router_a.tree().match_prefix(None, &unseen);
let nb = router_b.tree().match_prefix(None, &unseen);
assert_eq!(na.matched_blocks, 0, "router_a leaked unpublished key");
assert_eq!(nb.matched_blocks, 0, "router_b leaked unpublished key");
// 6. Both shutdowns must complete cleanly — no hang from the second
// subscriber holding a reference to a shared resource. The first
// drains under a generous ceiling (worker thread joins, mpsc
// receiver drop); the second has nothing left to wait on and
// must complete promptly. A slow second shutdown indicates the
// two subscribers were sharing a resource that serialized them.
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_a).shutdown()).await;
assert!(r.is_ok(), "router_a shutdown hung");
let t = std::time::Instant::now();
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_b).shutdown()).await;
assert!(r.is_ok(), "router_b shutdown hung");
let elapsed = t.elapsed();
assert!(
elapsed < Duration::from_millis(100),
"router_b shutdown after router_a drained took {elapsed:?}; \
expected <100ms (no shared-resource contention)",
);
}
/// Two PUB sockets (two workers) + two `KvEventIndex` instances (two
/// routers), each subscribed to **both** publishers. This is the real
/// v1 HA shape: each router replica fans out subscriptions across the
/// worker pool and merges every publisher's `BlockStored` stream into
/// its own tree. The companion 1-PUB test above only verifies broadcast
/// fan-out; this test verifies the per-worker attribution stays correct
/// when events arrive from multiple sources concurrently.
///
/// Property pinned: after publishing prefix `X` on `pub_x` and prefix
/// `Y` on `pub_y`, both trees report
/// * `match_prefix(X) = {full, workers={worker_x}}`
/// * `match_prefix(Y) = {full, workers={worker_y}}`
/// with no cross-attribution (worker_x must NOT appear in match(Y)).
/// A regression that wires both subscribers to the same internal
/// channel — or that mis-keys events by their arrival socket rather
/// than their announced worker URL — would surface here as cross-
/// contamination of the worker sets.
#[tokio::test]
async fn two_subscribers_merge_events_from_two_publishers() {
let (mut pub_x, port_x) = make_pub_bound().await;
let (mut pub_y, port_y) = make_pub_bound().await;
let worker_x = "http://127.0.0.1:30001";
let worker_y = "http://127.0.0.1:30002";
let block_size = 4u32;
let cfg_x = EventConfig {
host: "127.0.0.1".into(),
port_base: port_x,
topic: String::new(),
block_size,
dp_size: 1,
};
let cfg_y = EventConfig {
host: "127.0.0.1".into(),
port_base: port_y,
topic: String::new(),
block_size,
dp_size: 1,
};
// Both routers subscribe to BOTH workers — the production fan-out.
let router_a = KvEventIndex::new();
let router_b = KvEventIndex::new();
router_a.add_worker(worker_x, Some(cfg_x.clone())).await;
router_a.add_worker(worker_y, Some(cfg_y.clone())).await;
router_b.add_worker(worker_x, Some(cfg_x.clone())).await;
router_b.add_worker(worker_y, Some(cfg_y.clone())).await;
// Four SUB→PUB handshakes need to settle before publishing; missed
// SUBSCRIBE frames lose messages forever in PUB/SUB semantics.
tokio::time::sleep(Duration::from_millis(200)).await;
// Two non-overlapping token streams → two distinct hash chains. The
// gap between them (0..16 vs 1000..1016) keeps `compute_block_hashes`
// outputs disjoint so a cross-attribution bug can't be masked by
// hash collision.
let tokens_x: Vec<u32> = (0..16).collect();
let tokens_y: Vec<u32> = (1000..1016).collect();
let hashes_x = compute_block_hashes(&tokens_x, block_size as usize);
let hashes_y = compute_block_hashes(&tokens_y, block_size as usize);
assert!(hashes_x.len() >= 3 && hashes_y.len() >= 3);
let payload_x = encode_event_batch(
0.0,
vec![encode_block_stored_event(
&hashes_x, None, &tokens_x, block_size,
)],
Some(0),
);
let payload_y = encode_event_batch(
0.0,
vec![encode_block_stored_event(
&hashes_y, None, &tokens_y, block_size,
)],
Some(0),
);
pub_x
.send(build_multipart(1, payload_x))
.await
.expect("publish on pub_x");
pub_y
.send(build_multipart(1, payload_y))
.await
.expect("publish on pub_y");
let key_x = KvWorkerId {
url: worker_x.into(),
dp_rank: 0,
};
let key_y = KvWorkerId {
url: worker_y.into(),
dp_rank: 0,
};
let target_x = hashes_x.len();
let target_y = hashes_y.len();
let start = std::time::Instant::now();
loop {
let ax = router_a.tree().match_prefix(None, &hashes_x);
let ay = router_a.tree().match_prefix(None, &hashes_y);
let bx = router_b.tree().match_prefix(None, &hashes_x);
let by = router_b.tree().match_prefix(None, &hashes_y);
let converged = ax.matched_blocks == target_x
&& ay.matched_blocks == target_y
&& bx.matched_blocks == target_x
&& by.matched_blocks == target_y
&& ax.workers.contains(&key_x)
&& ay.workers.contains(&key_y)
&& bx.workers.contains(&key_x)
&& by.workers.contains(&key_y);
if converged {
// Negative attribution: prefix X must not be attributed to
// worker_y in either tree, and vice versa. A regression that
// keyed events by arriving socket rather than announced
// worker URL would set BOTH worker keys on each prefix.
assert!(
!ax.workers.contains(&key_y),
"router_a cross-attributed worker_y to prefix X: {:?}",
ax.workers,
);
assert!(
!ay.workers.contains(&key_x),
"router_a cross-attributed worker_x to prefix Y: {:?}",
ay.workers,
);
assert!(
!bx.workers.contains(&key_y),
"router_b cross-attributed worker_y to prefix X: {:?}",
bx.workers,
);
assert!(
!by.workers.contains(&key_x),
"router_b cross-attributed worker_x to prefix Y: {:?}",
by.workers,
);
break;
}
if start.elapsed() > Duration::from_secs(3) {
panic!(
"trees did not converge within 3s:\n \
router_a: X={{matched={}, workers={:?}}}, Y={{matched={}, workers={:?}}}\n \
router_b: X={{matched={}, workers={:?}}}, Y={{matched={}, workers={:?}}}\n \
targets: X={target_x}, Y={target_y}",
ax.matched_blocks,
ax.workers,
ay.matched_blocks,
ay.workers,
bx.matched_blocks,
bx.workers,
by.matched_blocks,
by.workers,
);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_a).shutdown()).await;
assert!(r.is_ok(), "router_a shutdown hung");
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_b).shutdown()).await;
assert!(r.is_ok(), "router_b shutdown hung");
}
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod zmq_helpers;
mod cache_aware_zmq;
mod kv_events_hash_parity;
mod kv_events_tree_concurrent;
mod kv_events_two_subscribers;
mod power_of_two;
mod round_robin;
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::power_of_two::PowerOfTwoChoicesPolicy;
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::workers::Worker;
use std::sync::atomic::Ordering;
use std::sync::Arc;
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 selects_lower_load() {
let a = worker("a");
let b = worker("b");
a.active_requests.store(10, Ordering::Relaxed);
b.active_requests.store(2, Ordering::Relaxed);
let p = PowerOfTwoChoicesPolicy::new();
let ws = vec![a.clone(), b.clone()];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let chosen = p.select(&ws, &ctx).unwrap();
assert_eq!(chosen.id.0, "b");
}
#[test]
fn distribution_skews_to_lower_load() {
// With 3 workers and one heavily loaded, the loaded one should win
// significantly less than 1/3 of selections.
let workers = vec![worker("a"), worker("b"), worker("c")];
workers[2].active_requests.store(100, Ordering::Relaxed); // c is loaded
let p = PowerOfTwoChoicesPolicy::new();
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let mut counts = std::collections::HashMap::new();
for _ in 0..1000 {
let w = p.select(&workers, &ctx).unwrap();
*counts.entry(w.id.0.clone()).or_insert(0) += 1;
}
let c_picks = *counts.get("c").unwrap_or(&0);
assert!(
c_picks < 200,
"loaded worker should be picked < 20% of the time, got {c_picks}"
);
}
#[test]
fn empty_returns_none() {
let p = PowerOfTwoChoicesPolicy::new();
let ws: Vec<Arc<Worker>> = vec![];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
assert!(p.select(&ws, &ctx).is_none());
}
#[test]
fn single_worker_returns_it() {
let p = PowerOfTwoChoicesPolicy::new();
let ws = vec![worker("only")];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
assert_eq!(p.select(&ws, &ctx).unwrap().id.0, "only");
}
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::round_robin::RoundRobinPolicy;
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::workers::Worker;
use std::sync::Arc;
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 cycles_through_workers() {
let p = RoundRobinPolicy::new();
let ws = vec![worker("a"), worker("b"), worker("c")];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let picks: Vec<_> = (0..6)
.filter_map(|_| p.select(&ws, &ctx))
.map(|w| w.id.0.clone())
.collect();
assert_eq!(picks, vec!["a", "b", "c", "a", "b", "c"]);
}
#[test]
fn empty_pool_returns_none() {
let p = RoundRobinPolicy::new();
let ws: Vec<Arc<Worker>> = vec![];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
assert!(p.select(&ws, &ctx).is_none());
}
#[test]
fn distribution_across_100_calls() {
let p = RoundRobinPolicy::new();
let ws = vec![worker("a"), worker("b"), worker("c")];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let mut counts = std::collections::HashMap::new();
for _ in 0..99 {
let w = p.select(&ws, &ctx).unwrap();
*counts.entry(w.id.0.clone()).or_insert(0) += 1;
}
assert_eq!(counts["a"], 33);
assert_eq!(counts["b"], 33);
assert_eq!(counts["c"], 33);
}
@@ -0,0 +1,88 @@
// 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
//! 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.
#![allow(dead_code)]
use bytes::Bytes;
use rmp::encode as mp;
use zeromq::{Endpoint, PubSocket, Socket, ZmqMessage};
/// Bind a PUB socket to an OS-assigned 127.0.0.1 port. Returns
/// `(socket, port)`.
pub async fn make_pub_bound() -> (PubSocket, u16) {
let mut sock = PubSocket::new();
let endpoint = sock
.bind("tcp://127.0.0.1:0")
.await
.expect("bind PUB socket");
let port = match endpoint {
Endpoint::Tcp(_, p) => p,
other => panic!("unexpected endpoint: {other:?}"),
};
(sock, port)
}
/// Encode a single `BlockStored` event in the wire format msgspec
/// emits. Layout: `["BlockStored", block_hashes, parent, token_ids,
/// block_size, lora_id, medium]`.
pub fn encode_block_stored_event(
block_hashes: &[i64],
parent: Option<i64>,
token_ids: &[u32],
block_size: u32,
) -> Vec<u8> {
let mut buf = Vec::new();
mp::write_array_len(&mut buf, 7).unwrap();
mp::write_str(&mut buf, "BlockStored").unwrap();
mp::write_array_len(&mut buf, block_hashes.len() as u32).unwrap();
for v in block_hashes {
mp::write_sint(&mut buf, *v).unwrap();
}
match parent {
Some(v) => {
mp::write_sint(&mut buf, v).unwrap();
}
None => mp::write_nil(&mut buf).unwrap(),
}
mp::write_array_len(&mut buf, token_ids.len() as u32).unwrap();
for v in token_ids {
mp::write_uint(&mut buf, *v as u64).unwrap();
}
mp::write_uint(&mut buf, block_size as u64).unwrap();
mp::write_nil(&mut buf).unwrap(); // lora_id
mp::write_str(&mut buf, "GPU").unwrap();
buf
}
/// Wrap one or more pre-encoded events into a KVEventBatch with
/// timestamp + optional dp-rank.
pub fn encode_event_batch(ts: f64, events: Vec<Vec<u8>>, attn_dp_rank: Option<u32>) -> Vec<u8> {
let mut buf = Vec::new();
mp::write_array_len(&mut buf, 3).unwrap();
mp::write_f64(&mut buf, ts).unwrap();
mp::write_array_len(&mut buf, events.len() as u32).unwrap();
for ev in events {
buf.extend_from_slice(&ev);
}
match attn_dp_rank {
Some(v) => {
mp::write_uint(&mut buf, v as u64).unwrap();
}
None => mp::write_nil(&mut buf).unwrap(),
}
buf
}
/// Build the two-frame ZMQ message a real KV publisher sends:
/// `[seq (big-endian i64), payload]`.
pub fn build_multipart(seq: i64, payload: Vec<u8>) -> ZmqMessage {
let mut msg = ZmqMessage::from(Bytes::new());
msg.push_back(Bytes::copy_from_slice(&seq.to_be_bytes()));
msg.push_back(Bytes::from(payload));
msg
}
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod parity;
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Bit-parity check: dynamo-tokenizers must produce the same token_ids as
//! SGLang's reference (transformers.AutoTokenizer) for every (model, shape)
//! fixture. Any drift is a regression.
//!
//! ## Running
//!
//! `cargo test --release --test component tokenizer::parity` runs the test.
//!
//! Each fixture cell needs the model's `tokenizer.json` on disk; the test
//! looks in the local HuggingFace cache (`HF_HOME` or `~/.cache/huggingface`).
//! Cells whose snapshot isn't cached are skipped (with a warning); cells
//! whose snapshot IS cached are asserted bit-identical.
//!
//! Locally, when no fixtures can be checked (fresh cache) the test emits a
//! warning and passes — useful for contributors without the model snapshots.
//! In CI (`SGLANG_IS_IN_CI=true`) the same condition is a hard failure: a
//! parity matrix that validates nothing is worse than no test at all, since
//! it gives a false sense of coverage. The e2e HTTP tokenize test remains
//! the authoritative live-model parity gate, but this matrix must actually
//! run against cached snapshots when present in CI.
//!
//! ## Regenerating fixtures
//!
//! Run `tests/scripts/generate_parity_fixtures.py` after changing a prompt
//! shape or adding a model, then commit the new JSON.
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Deserialize)]
struct Fixture {
model_id: String,
shape: String,
prompt_text: String,
expected_token_ids: Vec<u32>,
#[allow(dead_code)]
skip_special_tokens: bool,
}
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tokenizer_parity")
}
/// Resolve a model's tokenizer.json file from the local HF cache.
///
/// Strategy:
/// 1. Check HF_HOME env var, or default to ~/.cache/huggingface
/// 2. Look for models--<safe-name>/snapshots/<hash>/tokenizer.json
/// 3. Return None if not found — the test cell is skipped.
fn resolve_tokenizer_path(model_id: &str) -> Option<PathBuf> {
let hf_home = std::env::var("HF_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cache/huggingface")))?;
let safe = model_id.replace('/', "--");
let candidate = hf_home.join("hub").join(format!("models--{safe}"));
if !candidate.exists() {
return None;
}
let snapshots = candidate.join("snapshots");
let snap = std::fs::read_dir(&snapshots).ok()?.next()?.ok()?.path();
let tj = snap.join("tokenizer.json");
tj.exists().then_some(tj)
}
/// Parity matrix: dynamo-tokenizers vs. transformers.AutoTokenizer.
///
/// Skips cells whose tokenizer.json isn't in the local HF cache. See
/// module-level docs.
#[test]
fn parity_matrix() {
let mut checked = 0;
let mut skipped = vec![];
for model_dir in std::fs::read_dir(fixture_root()).unwrap() {
let model_dir = model_dir.unwrap().path();
if !model_dir.is_dir() {
continue;
}
for shape_file in std::fs::read_dir(&model_dir).unwrap() {
let p = shape_file.unwrap().path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let raw = std::fs::read_to_string(&p).unwrap();
let f: Fixture =
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", p.display()));
let Some(tp) = resolve_tokenizer_path(&f.model_id) else {
skipped.push((f.model_id.clone(), f.shape.clone()));
continue;
};
let tok = sgl_router::tokenizer::adapter::load(tp.to_str().unwrap()).unwrap();
let ids = sgl_router::tokenizer::adapter::encode(&tok, &f.prompt_text).unwrap();
assert_eq!(
ids, f.expected_token_ids,
"DRIFT on {}/{}",
f.model_id, f.shape
);
checked += 1;
}
}
let expected = std::fs::read_dir(fixture_root())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| {
std::fs::read_dir(e.path())
.unwrap()
.filter_map(|f| f.ok())
.filter(|f| f.path().extension().and_then(|s| s.to_str()) == Some("json"))
.count()
})
.sum::<usize>();
assert_eq!(
checked + skipped.len(),
expected,
"expected {expected} fixtures, found {}",
checked + skipped.len()
);
if checked == 0 {
let families: Vec<String> = skipped
.iter()
.map(|(m, _)| m.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let msg = format!(
"parity_matrix: no fixtures could be checked — HF cache empty? skipped {} cells \
across model families: [{}]. The e2e HTTP tokenize test remains the \
authoritative live-model parity gate.",
skipped.len(),
families.join(", "),
);
if std::env::var("SGLANG_IS_IN_CI").as_deref() == Ok("true") {
panic!(
"{msg}\n\nThis is a hard failure in CI: a parity test that validates zero \
cells provides no coverage. Either pre-populate the HF cache for these \
model families on the runner, or remove the parity test."
);
}
eprintln!("{msg}");
} else {
eprintln!(
"parity: {checked} cells passed, {} skipped (no HF snapshot)",
skipped.len()
);
}
}
@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Concurrent-state invariants for the worker/registry/breaker layer.
//!
//! These tests stress the lock-free / single-Mutex paths that production
//! traffic exercises in parallel: many requests calling `breaker.allow()`,
//! many discovery events racing with workers_for() reads, and LoadGuard
//! lifecycles under panics.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::health::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
use sgl_router::workers::{Worker, WorkerRegistry};
/// HalfOpen state must admit at most one probe at a time even under high
/// concurrency. N threads race `allow()` when the breaker is HalfOpen; the
/// invariant is that exactly one observes `true` (the probe holder); the
/// rest see `false` because `probe_in_flight` is already set.
#[tokio::test(start_paused = true)]
async fn breaker_half_open_admits_only_one_probe_concurrently() {
let cb = Arc::new(CircuitBreaker::with_config(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(1).unwrap(),
cool_down: Duration::from_millis(50),
}));
// Trip into Open.
cb.record_failure();
assert!(!cb.allow(), "must be Open immediately after a failure");
// Advance the paused clock past cool_down so the next `allow()` will
// attempt the Open → HalfOpen transition.
tokio::time::advance(Duration::from_millis(60)).await;
let admitted = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..32 {
let cb = cb.clone();
let admitted = admitted.clone();
handles.push(tokio::spawn(async move {
if cb.allow() {
admitted.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(
admitted.load(Ordering::Relaxed),
1,
"exactly one probe must be admitted in HalfOpen",
);
}
/// Concurrent `add_with_cb` (upsert) and `remove` from many threads on the
/// same WorkerId must not panic, must not deadlock, and must leave a
/// consistent index — `workers_for(model)` may return 0 or 1 worker, but
/// must never resolve to a worker that has been removed.
#[test]
fn registry_concurrent_add_remove_keeps_indexes_consistent() {
let r = Arc::new(WorkerRegistry::default());
let model = ModelId("m".into());
let mut handles = Vec::new();
for i in 0..8 {
let r = r.clone();
let model = model.clone();
handles.push(std::thread::spawn(move || {
for _ in 0..200 {
let _ = r.add(WorkerSpec {
id: WorkerId(format!("w{i}")),
url: format!("http://w{i}:30000"),
mode: WorkerMode::Plain,
model_ids: vec![model.clone()],
bootstrap_port: None,
});
let snapshot = r.workers_for(&model);
for w in &snapshot {
// Cross-index invariant: an entry surfaced via
// `by_model[m]` must come from a Worker whose own
// `model_ids` includes `m`. An earlier version of
// this assertion checked `w.id.0.starts_with('w')`,
// which is a tautology — every id is `w0..w7` by
// construction — and a regression where `by_model`
// pointed at the wrong Worker (e.g., a stale entry
// left after an upsert that should have cleared its
// by_model membership for the dropped model) would
// pass silently. We can't `re-get by_id and ptr_eq`
// because a concurrent remove can drop the by_id
// entry between the two reads — `Arc` keeps the
// Worker alive on our side but the index map is
// gone. The model-membership claim, however, is a
// property of the Arc itself and stays stable.
assert!(
w.model_ids.contains(&model),
"cross-index drift: by_model[{model:?}] surfaced \
{:?} whose own model_ids = {:?}",
w.id,
w.model_ids,
);
}
r.remove(&WorkerId(format!("w{i}")));
}
}));
}
for h in handles {
h.join().unwrap();
}
// After every thread finishes, every removed worker must really be gone.
assert!(
r.workers_for(&model).is_empty(),
"registry must be empty after all threads finished their add/remove cycles",
);
}
/// `LoadGuard` must decrement the counter during a panic-unwind, not just
/// on a normal scope exit. Rust's RAII contract via `Drop` covers this,
/// but a future refactor (e.g. adding a manual decrement on a non-panic
/// path) could silently regress it. This test pins the invariant.
#[test]
fn load_guard_decrements_on_panic_unwind() {
let w = Arc::new(Worker::new(WorkerSpec {
id: WorkerId("w".into()),
url: "http://x:30000".into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}));
assert_eq!(w.active_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);
panic!("synthetic panic to exercise Drop on unwind");
}));
assert!(result.is_err(), "the closure must have panicked");
assert_eq!(
w.active_load(),
0,
"LoadGuard's Drop must decrement even when the holder panics",
);
}
@@ -0,0 +1,511 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
use sgl_router::discovery::{DiscoveryEvent, ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::workers::{manager, WorkerRegistry};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
/// Spin up a tiny fake worker that returns `body` on `GET /server_info`.
/// Returns the worker base URL and a shutdown channel.
async fn spawn_fake_worker(body: Value) -> (String, oneshot::Sender<()>) {
let body = Arc::new(body);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
async move { Json((*body).clone()) }
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
fn spec_for(id: &str, url: &str, mode: WorkerMode) -> WorkerSpec {
// model_ids are intentionally empty: the manager resolves them via
// /server_info introspection. Pre-populating here would lie about
// what discovery backends actually emit.
WorkerSpec {
id: WorkerId(id.into()),
url: url.into(),
mode,
model_ids: Vec::new(),
bootstrap_port: None,
}
}
#[tokio::test]
async fn manager_processes_added_then_removed() {
let (url_a, _s_a) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (url_b, _s_b) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url_a,
WorkerMode::Plain,
)))
.await
.unwrap();
tx.send(DiscoveryEvent::Added(spec_for(
"w2",
&url_b,
WorkerMode::Plain,
)))
.await
.unwrap();
// Give the manager time to drain.
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(registry.workers_for(&ModelId("m".into())).len(), 2);
tx.send(DiscoveryEvent::Removed {
id: WorkerId("w1".into()),
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(registry.workers_for(&ModelId("m".into())).len(), 1);
drop(tx);
h.await.unwrap();
}
#[tokio::test]
async fn manager_handles_mode_changed() {
let (url, _s) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url,
WorkerMode::Prefill,
)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
registry
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.len(),
1
);
tx.send(DiscoveryEvent::ModeChanged {
id: WorkerId("w1".into()),
mode: WorkerMode::Decode,
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
registry
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.len(),
0
);
assert_eq!(
registry
.workers_for_mode(&ModelId("m".into()), WorkerMode::Decode)
.len(),
1
);
drop(tx);
h.await.unwrap();
}
#[tokio::test]
async fn mode_changed_preserves_active_requests_and_breaker() {
let (url, _s) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url,
WorkerMode::Prefill,
)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
// Grab a handle, bump active_requests, and open the breaker.
let w = registry.get(&WorkerId("w1".into())).unwrap();
w.active_requests.fetch_add(5, Ordering::Relaxed);
// Default threshold is 3 — record 10 failures to guarantee Open state.
for _ in 0..10 {
w.breaker.record_failure();
}
let breaker_open_before = !w.breaker.allow();
assert!(
breaker_open_before,
"breaker should be open after 10 failures"
);
// Flip mode via ModeChanged.
tx.send(DiscoveryEvent::ModeChanged {
id: WorkerId("w1".into()),
mode: WorkerMode::Decode,
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
// Re-fetch the Worker handle from the registry.
let w_after = registry.get(&WorkerId("w1".into())).unwrap();
assert_eq!(
w_after.mode(),
WorkerMode::Decode,
"mode should have flipped to Decode"
);
assert_eq!(
w_after.active_requests.load(Ordering::Relaxed),
5,
"active_requests should be preserved across mode change"
);
assert!(
!w_after.breaker.allow(),
"breaker open state should be preserved across mode change"
);
// Critical: the Arc identity must be the same — mutation in place.
assert!(
Arc::ptr_eq(&w, &w_after),
"Worker handle should be the SAME Arc, not a fresh replacement"
);
drop(tx);
h.await.unwrap();
}
/// An out-of-order `ModeChanged` for a worker the registry does not know
/// about (e.g. a buggy discovery backend reordered `Removed` and
/// `ModeChanged`) must not panic, must not silently log INFO claiming the
/// mode flip happened, and must leave the registry untouched.
#[tokio::test]
async fn manager_handles_orphan_mode_changed_without_panic() {
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::ModeChanged {
id: WorkerId("ghost".into()),
mode: WorkerMode::Decode,
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
registry.get(&WorkerId("ghost".into())).is_none(),
"an orphan ModeChanged must not create a phantom worker",
);
assert_eq!(
registry.workers_for(&ModelId("m".into())).len(),
0,
"registry must be empty after an orphan event",
);
drop(tx);
h.await.unwrap();
}
/// A `Removed` for an unknown id is a no-op — registry stays empty, manager
/// keeps running.
#[tokio::test]
async fn manager_handles_orphan_removed_without_panic() {
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Removed {
id: WorkerId("ghost".into()),
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(registry.is_empty());
drop(tx);
h.await.unwrap();
}
/// Duplicate `Added` for the same id is an upsert — the registry ends up
/// with exactly one worker. The model resolved by /server_info wins on
/// re-add (a different worker may advertise a different served model).
#[tokio::test]
async fn manager_handles_duplicate_added_as_upsert() {
let (url_first, _s_first) = spawn_fake_worker(json!({"served_model_name": "m1"})).await;
let (url_second, _s_second) = spawn_fake_worker(json!({"served_model_name": "m1"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url_first,
WorkerMode::Plain,
)))
.await
.unwrap();
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url_second,
WorkerMode::Plain,
)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
registry.workers_for(&ModelId("m1".into())).len(),
1,
"w1 still serves m1 after the second Added",
);
drop(tx);
h.await.unwrap();
}
/// Spawn a fake worker whose `/server_info` returns `body` only after
/// sleeping for `delay`. Returns the worker URL and a shutdown channel.
async fn spawn_slow_worker(body: Value, delay: Duration) -> (String, oneshot::Sender<()>) {
let body = Arc::new(body);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
async move {
tokio::time::sleep(delay).await;
Json((*body).clone())
}
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
/// Spawn a fake worker that counts each `GET /server_info` hit in the
/// returned `AtomicUsize`. Used to assert the manager makes exactly
/// one round-trip per worker.
async fn spawn_counting_worker(body: Value) -> (String, Arc<AtomicUsize>, oneshot::Sender<()>) {
let body = Arc::new(body);
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = counter.clone();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
let counter = counter_clone.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Json((*body).clone())
}
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), counter, tx)
}
/// Registration must run in parallel across multiple `Added` events.
/// Each fake worker delays its `/server_info` by 200ms; with sequential
/// processing the manager would take ≥1000ms for 5 workers. We allow
/// up to 600ms (3x the per-fetch delay) as a generous bound that still
/// rejects the sequential implementation.
#[tokio::test]
async fn added_events_run_in_parallel() {
let delay = Duration::from_millis(200);
let n = 5;
let mut workers = Vec::new();
for _ in 0..n {
workers.push(spawn_slow_worker(json!({"served_model_name": "m"}), delay).await);
}
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
let start = Instant::now();
for (i, (url, _s)) in workers.iter().enumerate() {
tx.send(DiscoveryEvent::Added(spec_for(
&format!("w{i}"),
url,
WorkerMode::Plain,
)))
.await
.unwrap();
}
let registered = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if registry.workers_for(&ModelId("m".into())).len() == n {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
let elapsed = start.elapsed();
assert!(registered.is_ok(), "manager failed to register {n} workers");
assert!(
elapsed < Duration::from_millis(600),
"registration of {n} workers took {elapsed:?}; sequential per-worker /server_info \
fetches would take ≥1000ms — parallel spawn is required"
);
drop(tx);
h.await.unwrap();
}
/// A `Removed` issued while the matching `Added` is still mid-fetch
/// must await the in-flight registration handle before removing.
/// Without that ordering the removal runs first (registry has nothing
/// to remove), then the Added's deferred registry write leaks the
/// worker.
#[tokio::test]
async fn removed_awaits_pending_added() {
let (url, _s) = spawn_slow_worker(
json!({"served_model_name": "m"}),
Duration::from_millis(300),
)
.await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w-slow",
&url,
WorkerMode::Plain,
)))
.await
.unwrap();
tx.send(DiscoveryEvent::Removed {
id: WorkerId("w-slow".into()),
})
.await
.unwrap();
// Wait long enough for the Added's /server_info to complete (300ms),
// then assert the worker is gone. If Removed ran before Added's
// registry write, the post-fetch write would leak the entry.
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(
registry.get(&WorkerId("w-slow".into())).is_none(),
"Removed must await the in-flight Added; otherwise the deferred \
registry write leaks the worker"
);
drop(tx);
h.await.unwrap();
}
/// The manager must make exactly ONE `/server_info` request per worker.
/// Before this fix the worker manager fetched `served_model_name` and
/// `KvEventIndex::add_worker` fetched the `kv_events` block
/// 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;
let body = json!({
"served_model_name": "m",
"kv_events": {
"publisher": "zmq",
"endpoint_host": "127.0.0.1",
"endpoint_port_base": 60100,
"topic": "",
"block_size": 64,
"dp_size": 1,
}
});
let (url, counter, _s) = spawn_counting_worker(body).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let kv_index = KvEventIndex::new();
let h = tokio::spawn(manager::run_with_config(
rx,
registry.clone(),
None,
Some(kv_index.clone()),
None,
));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url,
WorkerMode::Plain,
)))
.await
.unwrap();
// Wait for both the registry and kv-events index to reflect the worker.
let ready = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if registry.get(&WorkerId("w1".into())).is_some() && kv_index.known_worker_count() == 1
{
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
ready.is_ok(),
"manager did not finish onboarding the worker"
);
let hits = counter.load(Ordering::SeqCst);
assert_eq!(
hits, 1,
"manager must fetch /server_info exactly once per worker (got {hits})"
);
drop(tx);
h.await.unwrap();
kv_index.shutdown().await;
}
@@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod concurrent_state;
mod manager;