// 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 = (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 = (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 = (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 { 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)); } }