Files

267 lines
9.7 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Concurrent-mutation stress test for `HashTree`.
//!
//! The inline tests in `state::kv_events::tree` are all
//! single-threaded. Under production load, multiple worker subscribers
//! drive `insert` / `remove` / `clear_worker` against the same tree from
//! tokio worker threads while the chat handler simultaneously calls
//! `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::state::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.holds(&w),
"full match must include worker; got {:?}",
m.workers(),
);
}
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
mutator.join().unwrap();
}
/// Reader storm concurrent with a writer hammering insert/remove on
/// DISTINCT chain roots — the pattern sharding targets. Asserts
/// CORRECTNESS under that contention: warm chains are pre-inserted and
/// never removed, so a reader must always get a full match with the warm
/// worker present, and the writer's scratch chains are fully removed each
/// round, so after join only the warm chains remain.
///
/// No sleeps, no wall-clock — the readers run a fixed number of bounded
/// iterations and the writer churns until they are done, so nothing here
/// can flake on timing.
#[test]
fn readers_unaffected_by_writer_on_distinct_roots() {
let tree = Arc::new(HashTree::new());
// Fixed chains the readers query and the writer never touches, on
// distinct roots (1_000 apart) so they spread across shards.
let warm = KvWorkerId {
url: "http://warm:30000".into(),
dp_rank: 0,
};
const WARM_CHAINS: i64 = 16;
let warm_chain = |c: i64| -> Vec<i64> { vec![c * 1_000, c * 1_000 + 1, c * 1_000 + 2] };
for c in 0..WARM_CHAINS {
tree.insert(&warm, None, &warm_chain(c));
}
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
// Scratch chains on distinct roots far from the warm space, insert then
// immediate remove. Runs until the readers are done rather than for a
// fixed count the writer could burn through early, leaving the test with
// no contention at all. Every round is a complete pair, so stopping at
// any point leaves no residue.
let writer = {
let tree = tree.clone();
let stop = stop.clone();
thread::spawn(move || {
let scratch = KvWorkerId {
url: "http://scratch:30000".into(),
dp_rank: 0,
};
let mut round = 0i64;
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
// Cycled so the arithmetic cannot drift into the warm space.
let base = 1_000_000 + (round % 100_000) * 7;
let chain = [base, base + 1, base + 2, base + 3];
tree.insert(&scratch, None, &chain);
tree.remove(&scratch, &chain);
round = round.wrapping_add(1);
}
})
};
// Each reader asserts the warm worker is present at full depth on every
// warm chain, regardless of writer churn.
let mut readers = Vec::new();
for _ in 0..4 {
let tree = tree.clone();
let warm = warm.clone();
readers.push(thread::spawn(move || {
for _ in 0..2_000 {
for c in 0..WARM_CHAINS {
let chain = warm_chain(c);
let m = tree.match_prefix(None, &chain);
assert_eq!(
m.matched_blocks,
chain.len(),
"warm chain {c} must always fully match despite writer churn",
);
assert!(
m.holds(&warm),
"warm worker must always hold its own untouched chain",
);
}
}
}));
}
for r in readers {
r.join().expect("reader thread panicked under contention");
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
writer
.join()
.expect("writer thread panicked under contention");
// Only the warm chains remain: 16 chains x 3 nodes = 48 non-root nodes.
assert_eq!(
tree.node_count(),
(WARM_CHAINS * 3) as usize,
"writer's scratch churn must leave no residual nodes",
);
for c in 0..WARM_CHAINS {
let m = tree.match_prefix(None, &warm_chain(c));
assert_eq!(m.matched_blocks, 3);
assert!(m.holds(&warm));
}
}