[Router] Honor KV-event storage tiers in the cache-aware tree (1/4) (#39108)
Co-authored-by: Kangyan Zhou <kangyan.zhou@radixark.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Kangyan Zhou
Claude Opus 5
parent
4b186cfea5
commit
45b511b2ef
@@ -38,7 +38,7 @@ use tracing::{debug, info, warn};
|
||||
use super::block_size_oracle::BlockSizeOracle;
|
||||
use super::discovery::{fetch_event_config, EventConfig};
|
||||
use super::subscriber::{KvEventSubscriberRegistry, SubKind, WorkerEvent};
|
||||
use super::tree::{HashTree, KvWorkerId};
|
||||
use super::tree::{HashTree, KvWorkerId, Tiers};
|
||||
use super::wire::KvCacheEvent;
|
||||
use crate::policies::engine_load::EngineLoadTable;
|
||||
|
||||
@@ -482,12 +482,25 @@ async fn pump_loop(
|
||||
}
|
||||
}
|
||||
for event in &batch.events {
|
||||
// The `medium` tag decides which tier a store lands on and
|
||||
// which tier a removal clears, so a device eviction leaves
|
||||
// a worker that still holds the block on host as an owner
|
||||
// — see the tree's "Storage tiers" docs.
|
||||
match event {
|
||||
KvCacheEvent::BlockStored(b) => {
|
||||
tree.insert(&worker, b.parent_block_hash, &b.block_hashes);
|
||||
tree.insert_tiered(
|
||||
&worker,
|
||||
b.parent_block_hash,
|
||||
&b.block_hashes,
|
||||
Tiers::for_store(b.medium.as_deref()),
|
||||
);
|
||||
}
|
||||
KvCacheEvent::BlockRemoved(b) => {
|
||||
tree.remove(&worker, &b.block_hashes);
|
||||
tree.remove_tiered(
|
||||
&worker,
|
||||
&b.block_hashes,
|
||||
Tiers::for_remove(b.medium.as_deref()),
|
||||
);
|
||||
}
|
||||
KvCacheEvent::AllBlocksCleared => {
|
||||
tree.clear_worker(&worker);
|
||||
@@ -593,7 +606,54 @@ mod tests {
|
||||
|
||||
let m = tree.match_prefix(None, &[10, 20, 30]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert!(m.workers.contains(&id), "tree must hold the worker");
|
||||
assert!(m.workers().contains(&id), "tree must hold the worker");
|
||||
}
|
||||
|
||||
/// The pump must carry each event's `medium` into the tree. The engine's
|
||||
/// write-back sequence for a backed-up block is a host-tagged store
|
||||
/// followed by a device-tagged removal; applied tier-blind, the removal
|
||||
/// erased the worker and every repeat of that prefix routed cold for the
|
||||
/// whole host retention horizon.
|
||||
#[tokio::test]
|
||||
async fn pump_keeps_host_backed_block_owned_across_device_eviction() {
|
||||
let id = worker_id("http://w1", 0);
|
||||
let h = spawn_pump(std::slice::from_ref(&id));
|
||||
let (tree, tx, pump) = (h.tree, h.tx, h.pump);
|
||||
|
||||
let stored = |medium: Option<&str>| {
|
||||
KvCacheEvent::BlockStored(BlockStored {
|
||||
parent_block_hash: None,
|
||||
block_hashes: vec![10, 20],
|
||||
token_ids: vec![],
|
||||
block_size: 64,
|
||||
lora_id: None,
|
||||
medium: medium.map(str::to_owned),
|
||||
})
|
||||
};
|
||||
let removed = |medium: Option<&str>| {
|
||||
KvCacheEvent::BlockRemoved(BlockRemoved {
|
||||
block_hashes: vec![20],
|
||||
medium: medium.map(str::to_owned),
|
||||
})
|
||||
};
|
||||
tx.send(WorkerEvent::Batch {
|
||||
worker: id.clone(),
|
||||
seq: 1,
|
||||
batch: batch(vec![
|
||||
stored(Some("GPU")),
|
||||
stored(Some("CPU_PINNED")),
|
||||
removed(Some("GPU")),
|
||||
]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
pump.await.unwrap();
|
||||
|
||||
let m = tree.match_prefix(None, &[10, 20]);
|
||||
assert_eq!(m.matched_blocks, 2, "host copy keeps the block routable");
|
||||
assert!(m.workers().contains(&id));
|
||||
assert!(!m.device_workers().contains(&id), "device copy is gone");
|
||||
}
|
||||
|
||||
/// A `WorkerEvent::Load` lands in the engine-load table (gauge, no
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! - [`wire`] — msgpack types and [`decode_event_batch`]; the contract
|
||||
//! with the SGLang publisher. Pure decoding; no I/O.
|
||||
//! - [`hash`] — block-hash compute mirroring SGLang `RadixKey.hash_page`.
|
||||
//! - [`tree`] — hash-keyed radix tree consumed by the routing path.
|
||||
//! - [`tree`] — hash-keyed radix tree consumed by the routing path,
|
||||
//! tracking the storage tier each worker holds a block on.
|
||||
//! - [`subscriber`] — per-worker ZMQ SUB tasks.
|
||||
//! - [`discovery`] — `/server_info` parse → publisher endpoint.
|
||||
//! - [`index`] — public façade bundling the tree + subscribers + pump.
|
||||
@@ -29,7 +30,7 @@ pub use discovery::{fetch_event_config, EventConfig};
|
||||
pub use hash::{compute_block_hashes, compute_block_hashes_bigram, sha256_to_i64};
|
||||
pub use index::KvEventIndex;
|
||||
pub use subscriber::{KvEventSubscriberRegistry, SubKind, WorkerEvent};
|
||||
pub use tree::{HashTree, KvWorkerId, MatchResult};
|
||||
pub use tree::{HashTree, KvWorkerId, MatchResult, Tiers};
|
||||
pub use wire::{
|
||||
decode_event_batch, BlockRemoved, BlockStored, DecodeError, KvCacheEvent, KvEventBatch,
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! Each non-root node represents one block hash (`i64`). A node's children
|
||||
//! are keyed by the *next* block hash in a chain, so a path from the root
|
||||
//! down to depth `n` represents a chain of `n` block hashes. Every node
|
||||
//! tracks the set of [`KvWorkerId`]s that hold the chain ending at that
|
||||
//! node.
|
||||
//! tracks the [`KvWorkerId`]s that hold the chain ending at that node, and
|
||||
//! on which storage [`Tiers`] each of them holds it.
|
||||
//!
|
||||
//! The tree is fed by `BlockStored` / `BlockRemoved` / `AllBlocksCleared`
|
||||
//! events from SGLang workers (decoded by [`super::wire`]) and is queried
|
||||
@@ -39,6 +39,41 @@
|
||||
//! from the reverse index. Pruning cascades upward iteratively (chains
|
||||
//! can be deep — the recursive form would risk stack-overflow for
|
||||
//! pathological inputs).
|
||||
//!
|
||||
//! # Storage tiers
|
||||
//!
|
||||
//! An engine running a hierarchical cache holds a block on device and, once
|
||||
//! it has been backed up, on host pinned memory (or a storage backend) as
|
||||
//! well. It publishes every tier transition as its own event, tagged with a
|
||||
//! `medium`: a host-tier `BlockStored` when the backup lands, a device-tier
|
||||
//! `BlockRemoved` when the device copy is evicted, a host-tier `BlockRemoved`
|
||||
//! when the host copy goes.
|
||||
//!
|
||||
//! The two orderings differ by write policy, and neither may be assumed.
|
||||
//! Under write-through the pending D2H copy holds a lock ref, so the host
|
||||
//! store is published before the device eviction. Under write-back
|
||||
//! `_detach_backuped` publishes the device removal as soon as host slots are
|
||||
//! reserved and the host store follows only when the copy lands — the
|
||||
//! inverse. The tree therefore has to converge either way, and it does: a
|
||||
//! removal clears only its own tier, and a later store re-adds the chain.
|
||||
//!
|
||||
//! WHY the tree keeps the tiers apart instead of treating any `BlockRemoved`
|
||||
//! as "the worker lost the block": a device eviction that leaves a host copy
|
||||
//! behind does not end the worker's ability to serve the prefix — it loads
|
||||
//! the copy back at memory speed instead of recomputing it. Dropping the
|
||||
//! worker on that event makes every prefix unroutable after one device
|
||||
//! turnover, even though the fleet still holds it for the whole host
|
||||
//! retention horizon, and the repeat request lands on a random worker that
|
||||
//! then prefills it cold. Ownership here is "any tier", which is what makes
|
||||
//! the prefix routable again. [`MatchResult::tiers`] reports which tier each
|
||||
//! owner holds it on so a policy can price a load-back against an in-place
|
||||
//! hit; no policy consumes it yet — the routing path reads
|
||||
//! [`HashTree::prefix_depths`], which is tier-blind by design.
|
||||
//!
|
||||
//! Untagged events keep their pre-tiering meaning: an untagged store is a
|
||||
//! device store, an untagged remove clears every tier. A store tagged with a
|
||||
//! `medium` this build cannot rank is dropped rather than filed under a
|
||||
//! guess, while a removal tagged with one clears every tier. See [`Tiers`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -96,15 +131,173 @@ impl KvWorkerId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of [`HashTree::match_prefix`].
|
||||
#[derive(Debug, Clone)]
|
||||
/// The storage tiers on which one worker holds one block — a bitset, because
|
||||
/// a backed-up block sits on device AND host at once (module docs, "Storage
|
||||
/// tiers").
|
||||
///
|
||||
/// A worker owns a block, and is a routing candidate for it, while ANY bit is
|
||||
/// set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub struct Tiers(u8);
|
||||
|
||||
impl Tiers {
|
||||
/// Device HBM: `medium = "GPU"`, or an untagged event from a publisher
|
||||
/// that predates tiering.
|
||||
pub const DEVICE: Tiers = Tiers(1);
|
||||
/// Host pinned memory: `medium = "CPU_PINNED"`.
|
||||
pub const HOST: Tiers = Tiers(1 << 1);
|
||||
/// L3 local SSD / NVMe: `medium = "DISK"`.
|
||||
pub const DISK: Tiers = Tiers(1 << 2);
|
||||
/// L4 shared / remote pool, e.g. Mooncake: `medium = "EXTERNAL"`.
|
||||
///
|
||||
/// Separate from [`Self::DISK`] rather than folded into one "storage" bit
|
||||
/// because the engine treats them as distinct tiers
|
||||
/// (`StorageMedium` in `python/sglang/srt/disaggregation/kv_events.py`).
|
||||
/// Sharing a bit would make an L3 eviction erase the router's knowledge of
|
||||
/// an L4 copy on a fleet running both.
|
||||
pub const EXTERNAL: Tiers = Tiers(1 << 3);
|
||||
/// Every tier — what an untagged `BlockRemoved` clears.
|
||||
pub const ALL: Tiers = Self::DEVICE
|
||||
.union(Self::HOST)
|
||||
.union(Self::DISK)
|
||||
.union(Self::EXTERNAL);
|
||||
/// The `StorageMedium` strings SGLang puts on the wire
|
||||
/// (`python/sglang/srt/disaggregation/kv_events.py`) and the tier each
|
||||
/// lands on. The single source for the tree's ranking.
|
||||
pub const WIRE_MEDIA: [(&'static str, Tiers); 4] = [
|
||||
("GPU", Self::DEVICE),
|
||||
("CPU_PINNED", Self::HOST),
|
||||
("DISK", Self::DISK),
|
||||
("EXTERNAL", Self::EXTERNAL),
|
||||
];
|
||||
|
||||
/// The tiers a `BlockStored` tagged `medium` lands on. Untagged reads as
|
||||
/// device, which is what the event meant before tiers existed. An unknown
|
||||
/// tag lands on NO tier: the store is dropped rather than filed under a
|
||||
/// guess.
|
||||
///
|
||||
/// Filing it on device would turn every store on a future medium into a
|
||||
/// device hit. Filing it on an "unranked" catch-all tier is no better — it
|
||||
/// makes the worker a routing candidate for a tier whose cost this build
|
||||
/// cannot price, and a load-back the router assumes is cheap may be a
|
||||
/// remote fetch dearer than recomputing the prefix elsewhere. Dropping
|
||||
/// costs at most one cold prefill and is visible: the event still counts
|
||||
/// on the tally's `unknown` medium row, and the string is logged once.
|
||||
pub fn for_store(medium: Option<&str>) -> Tiers {
|
||||
match medium {
|
||||
None => Self::DEVICE,
|
||||
Some(m) => Self::known(m).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiers a `BlockRemoved` tagged `medium` clears: its own, or every
|
||||
/// tier when the tag is absent or unrecognised.
|
||||
///
|
||||
/// Deliberately asymmetric with [`Self::for_store`], which drops an
|
||||
/// unknown tag instead of widening it. A removal is the one direction
|
||||
/// where guessing wide is safe and guessing narrow is not: over-removal
|
||||
/// costs at most one cold prefill, while a permanently stale owner is the
|
||||
/// one failure a routing index must never manufacture. An engine that
|
||||
/// stores under a `medium` this build knows but frees the block under one
|
||||
/// it does not would otherwise strand that bit until the worker is
|
||||
/// dropped entirely.
|
||||
pub fn for_remove(medium: Option<&str>) -> Tiers {
|
||||
medium.and_then(Self::known).unwrap_or(Self::ALL)
|
||||
}
|
||||
|
||||
fn known(medium: &str) -> Option<Tiers> {
|
||||
Self::WIRE_MEDIA
|
||||
.iter()
|
||||
.find(|(name, _)| *name == medium)
|
||||
.map(|(_, tier)| *tier)
|
||||
}
|
||||
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
|
||||
/// Whether every bit of `other` is set here.
|
||||
pub const fn contains(self, other: Tiers) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, other: Tiers) {
|
||||
self.0 |= other.0;
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, other: Tiers) {
|
||||
self.0 &= !other.0;
|
||||
}
|
||||
|
||||
/// The bits set in either. `const` so the tier tables can be built from
|
||||
/// the individual tiers rather than from raw bit arithmetic.
|
||||
pub const fn union(self, other: Tiers) -> Tiers {
|
||||
Tiers(self.0 | other.0)
|
||||
}
|
||||
}
|
||||
|
||||
// `ALL` has to stay in step with the individual tiers, and nothing about
|
||||
// adding a `pub const` tier would otherwise force it: a bit missing from
|
||||
// `ALL` is a bit an untagged `BlockRemoved` never clears — a permanently
|
||||
// stale owner, the one failure `for_remove` exists to prevent.
|
||||
const _: () = {
|
||||
let mut i = 0;
|
||||
while i < Tiers::WIRE_MEDIA.len() {
|
||||
assert!(
|
||||
Tiers::ALL.contains(Tiers::WIRE_MEDIA[i].1),
|
||||
"every wire medium must map to a tier `ALL` clears",
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
};
|
||||
|
||||
/// Add `tiers` to `worker`'s hold in `carriers`, creating the entry on first
|
||||
/// sight. One lookup on the re-store path — under a hierarchical cache the
|
||||
/// host backup of a chain the worker already holds on device, the common
|
||||
/// case — and two on first sight. Never leaves an entry with no bits, which
|
||||
/// [`TreeState::remove`] relies on.
|
||||
fn add_tiers(carriers: &mut HashMap<KvWorkerId, Tiers>, worker: &KvWorkerId, tiers: Tiers) {
|
||||
match carriers.get_mut(worker) {
|
||||
Some(held) => held.insert(tiers),
|
||||
None => {
|
||||
carriers.insert(worker.clone(), tiers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of [`HashTree::match_prefix`]. `Default` is the no-match result:
|
||||
/// zero depth, no carriers on any tier.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MatchResult {
|
||||
/// Number of leading block hashes from the input slice that matched a
|
||||
/// path from the root.
|
||||
pub matched_blocks: usize,
|
||||
/// Workers holding the deepest matched node. Empty when
|
||||
/// `matched_blocks == 0`.
|
||||
pub workers: HashSet<KvWorkerId>,
|
||||
/// Every worker holding the deepest matched node, and the tiers it holds
|
||||
/// it on. A worker without the device bit serves the prefix by loading it
|
||||
/// back from a lower tier — cheaper than a cold prefill, dearer than
|
||||
/// serving in place — which is the ordering a policy would prefer on.
|
||||
/// Empty when `matched_blocks == 0`.
|
||||
///
|
||||
/// The single carrier list: [`Self::workers`] and [`Self::device_workers`]
|
||||
/// are derived from it, so no two views of one match can disagree.
|
||||
pub tiers: HashMap<KvWorkerId, Tiers>,
|
||||
}
|
||||
|
||||
impl MatchResult {
|
||||
/// Workers holding the deepest matched node on ANY tier.
|
||||
pub fn workers(&self) -> HashSet<KvWorkerId> {
|
||||
self.tiers.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// The subset of [`Self::workers`] holding the deepest matched node on
|
||||
/// device.
|
||||
pub fn device_workers(&self) -> HashSet<KvWorkerId> {
|
||||
self.tiers
|
||||
.iter()
|
||||
.filter(|(_, tiers)| tiers.contains(Tiers::DEVICE))
|
||||
.map(|(w, _)| w.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal stable handle to a tree node.
|
||||
@@ -140,7 +333,10 @@ struct Node {
|
||||
parent_block_hash: Option<i64>,
|
||||
/// `None` only for the root sentinel.
|
||||
parent: Option<NodeId>,
|
||||
workers: HashSet<KvWorkerId>,
|
||||
/// Carriers of the chain ending here, each with the tiers it holds the
|
||||
/// block on. A carrier is dropped the moment its last tier bit clears;
|
||||
/// an entry with empty tiers never exists (see [`TreeState::insert`]).
|
||||
workers: HashMap<KvWorkerId, Tiers>,
|
||||
/// Children keyed by next-block hash.
|
||||
children: HashMap<i64, NodeId>,
|
||||
last_used: AtomicU64,
|
||||
@@ -152,7 +348,7 @@ impl Node {
|
||||
block_hash,
|
||||
parent_block_hash,
|
||||
parent: Some(parent),
|
||||
workers: HashSet::new(),
|
||||
workers: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
last_used: AtomicU64::new(now_millis()),
|
||||
}
|
||||
@@ -192,7 +388,7 @@ impl TreeState {
|
||||
block_hash: ROOT_HASH_SENTINEL,
|
||||
parent_block_hash: None,
|
||||
parent: None,
|
||||
workers: HashSet::new(),
|
||||
workers: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
last_used: AtomicU64::new(now_millis()),
|
||||
},
|
||||
@@ -273,7 +469,7 @@ impl TreeState {
|
||||
if self
|
||||
.nodes
|
||||
.get(&cand)
|
||||
.is_some_and(|n| n.workers.contains(worker))
|
||||
.is_some_and(|n| n.workers.contains_key(worker))
|
||||
{
|
||||
return cand;
|
||||
}
|
||||
@@ -288,8 +484,18 @@ impl TreeState {
|
||||
ROOT_ID
|
||||
}
|
||||
|
||||
fn insert(&mut self, worker: &KvWorkerId, parent_hash: Option<i64>, block_hashes: &[i64]) {
|
||||
if block_hashes.is_empty() {
|
||||
/// Mark every node along `block_hashes` as held by `worker` on `tiers`,
|
||||
/// adding to whatever tiers it already holds there. Empty `tiers` is a
|
||||
/// no-op: a carrier entry with no tier would be an owner of nothing, and
|
||||
/// `remove` relies on "no bits ⇒ no entry" to know when to prune.
|
||||
fn insert(
|
||||
&mut self,
|
||||
worker: &KvWorkerId,
|
||||
parent_hash: Option<i64>,
|
||||
block_hashes: &[i64],
|
||||
tiers: Tiers,
|
||||
) {
|
||||
if block_hashes.is_empty() || tiers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut current = self.resolve_parent(worker, parent_hash);
|
||||
@@ -315,14 +521,17 @@ impl TreeState {
|
||||
);
|
||||
return;
|
||||
};
|
||||
child.workers.insert(worker.clone());
|
||||
add_tiers(&mut child.workers, worker, tiers);
|
||||
child.last_used.store(now, Ordering::Relaxed);
|
||||
current = child_id;
|
||||
prev_hash = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, worker: &KvWorkerId, block_hashes: &[i64]) {
|
||||
/// Clear `tiers` from `worker`'s hold on every node carrying any hash in
|
||||
/// `block_hashes`. The worker leaves a node once no tier bit remains, and
|
||||
/// a node that becomes empty + childless is pruned.
|
||||
fn remove(&mut self, worker: &KvWorkerId, block_hashes: &[i64], tiers: Tiers) {
|
||||
// Collect all node ids to touch (fixed snapshot — avoids iterator
|
||||
// invalidation when pruning mutates `by_hash`).
|
||||
let mut targets: Vec<NodeId> = Vec::new();
|
||||
@@ -334,14 +543,19 @@ impl TreeState {
|
||||
for id in targets {
|
||||
// Node may already be gone if a previous prune in this batch
|
||||
// cascaded through it — skip silently.
|
||||
let still_present = match self.nodes.get_mut(&id) {
|
||||
let prunable = match self.nodes.get_mut(&id) {
|
||||
Some(node) => {
|
||||
node.workers.remove(worker);
|
||||
if let Some(held) = node.workers.get_mut(worker) {
|
||||
held.remove(tiers);
|
||||
if held.is_empty() {
|
||||
node.workers.remove(worker);
|
||||
}
|
||||
}
|
||||
node.workers.is_empty() && node.children.is_empty()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if still_present {
|
||||
if prunable {
|
||||
self.prune_cascade(id);
|
||||
}
|
||||
}
|
||||
@@ -358,7 +572,7 @@ impl TreeState {
|
||||
let mut prune_candidates: Vec<NodeId> = Vec::new();
|
||||
for id in ids {
|
||||
if let Some(node) = self.nodes.get_mut(&id) {
|
||||
if node.workers.remove(worker)
|
||||
if node.workers.remove(worker).is_some()
|
||||
&& node.workers.is_empty()
|
||||
&& node.children.is_empty()
|
||||
{
|
||||
@@ -448,10 +662,7 @@ impl TreeState {
|
||||
/// [`HashTree::match_prefix`] documents the policy for callers.
|
||||
fn match_prefix(&self, parent_hash: Option<i64>, block_hashes: &[i64]) -> MatchResult {
|
||||
if block_hashes.is_empty() {
|
||||
return MatchResult {
|
||||
matched_blocks: 0,
|
||||
workers: HashSet::new(),
|
||||
};
|
||||
return MatchResult::default();
|
||||
}
|
||||
// Determine starting node: root, or the unique node carrying
|
||||
// `parent_hash`. Multiple matches: bail to root (caller should
|
||||
@@ -487,17 +698,13 @@ impl TreeState {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let workers = match last_match_node {
|
||||
Some(id) => self
|
||||
.nodes
|
||||
.get(&id)
|
||||
.map(|n| n.workers.clone())
|
||||
.unwrap_or_default(),
|
||||
None => HashSet::new(),
|
||||
};
|
||||
let tiers: HashMap<KvWorkerId, Tiers> = last_match_node
|
||||
.and_then(|id| self.nodes.get(&id))
|
||||
.map(|n| n.workers.clone())
|
||||
.unwrap_or_default();
|
||||
MatchResult {
|
||||
matched_blocks: matched,
|
||||
workers,
|
||||
tiers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,12 +752,14 @@ impl TreeState {
|
||||
reached += 1;
|
||||
if reached == 1 {
|
||||
// Absent here means never in `alive`: a tail held without
|
||||
// block 0 is reported as holding nothing.
|
||||
alive = child.workers.iter().collect();
|
||||
// block 0 is reported as holding nothing. Presence is "on any
|
||||
// tier" — a worker whose device copy was evicted but whose
|
||||
// host backup remains still holds the level.
|
||||
alive = child.workers.keys().collect();
|
||||
} else {
|
||||
let mut still = Vec::with_capacity(alive.len());
|
||||
for w in alive {
|
||||
if child.workers.contains(w) {
|
||||
if child.workers.contains_key(w) {
|
||||
still.push(w);
|
||||
} else {
|
||||
depths.insert(w, reached - 1);
|
||||
@@ -665,27 +874,51 @@ impl HashTree {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a `BlockStored` event.
|
||||
///
|
||||
/// Walks from `parent_hash`'s node (or root) and descends along
|
||||
/// `block_hashes`, marking every visited node as held by `worker`.
|
||||
/// Empty `block_hashes` is a no-op.
|
||||
/// Apply an untagged `BlockStored` event: a device store. See
|
||||
/// [`Self::insert_tiered`].
|
||||
pub fn insert(&self, worker: &KvWorkerId, parent_hash: Option<i64>, block_hashes: &[i64]) {
|
||||
let mut state = self.state.write();
|
||||
state.insert(worker, parent_hash, block_hashes);
|
||||
self.insert_tiered(worker, parent_hash, block_hashes, Tiers::DEVICE);
|
||||
}
|
||||
|
||||
/// Apply a `BlockRemoved` event.
|
||||
/// Apply a `BlockStored` event on `tiers` (from its `medium`, via
|
||||
/// [`Tiers::for_store`]).
|
||||
///
|
||||
/// For every node carrying any hash in `block_hashes`, drop `worker`
|
||||
/// from that node's worker set. Nodes that become empty AND childless
|
||||
/// are pruned (cascading upward).
|
||||
/// Walks from `parent_hash`'s node (or root) and descends along
|
||||
/// `block_hashes`, marking every visited node as held by `worker` on
|
||||
/// `tiers` in addition to any tier it already holds there. Empty
|
||||
/// `block_hashes` or empty `tiers` is a no-op.
|
||||
pub fn insert_tiered(
|
||||
&self,
|
||||
worker: &KvWorkerId,
|
||||
parent_hash: Option<i64>,
|
||||
block_hashes: &[i64],
|
||||
tiers: Tiers,
|
||||
) {
|
||||
let mut state = self.state.write();
|
||||
state.insert(worker, parent_hash, block_hashes, tiers);
|
||||
}
|
||||
|
||||
/// Apply an untagged `BlockRemoved` event: the worker loses the blocks on
|
||||
/// every tier. See [`Self::remove_tiered`].
|
||||
pub fn remove(&self, worker: &KvWorkerId, block_hashes: &[i64]) {
|
||||
self.remove_tiered(worker, block_hashes, Tiers::ALL);
|
||||
}
|
||||
|
||||
/// Apply a `BlockRemoved` event for `tiers` (from its `medium`, via
|
||||
/// [`Tiers::for_remove`]).
|
||||
///
|
||||
/// For every node carrying any hash in `block_hashes`, clear `tiers`
|
||||
/// from `worker`'s hold on it. The worker stays an owner of the node
|
||||
/// while it holds the block on any other tier — a device eviction after a
|
||||
/// host backup leaves the worker a host-tier owner. Once no tier remains
|
||||
/// the worker is dropped, and nodes that become empty AND childless are
|
||||
/// pruned (cascading upward).
|
||||
///
|
||||
/// Removing the worker from a node does NOT remove the node if other
|
||||
/// workers still hold it.
|
||||
pub fn remove(&self, worker: &KvWorkerId, block_hashes: &[i64]) {
|
||||
pub fn remove_tiered(&self, worker: &KvWorkerId, block_hashes: &[i64], tiers: Tiers) {
|
||||
let mut state = self.state.write();
|
||||
state.remove(worker, block_hashes);
|
||||
state.remove(worker, block_hashes, tiers);
|
||||
}
|
||||
|
||||
/// Apply an `AllBlocksCleared` event for `worker`.
|
||||
@@ -767,6 +1000,20 @@ impl HashTree {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
impl HashTree {
|
||||
/// Whether every carrier on every node holds at least one tier. `remove`
|
||||
/// and `prune_cascade` rely on "no bits ⇒ no entry"; a violation means a
|
||||
/// node can never be pruned and a worker never dropped.
|
||||
fn debug_no_empty_carrier(&self) -> bool {
|
||||
let state = self.state.read();
|
||||
state
|
||||
.nodes
|
||||
.values()
|
||||
.all(|n| n.workers.values().all(|t| !t.is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -809,7 +1056,7 @@ mod tests {
|
||||
// so `match_prefix` credits it with the full chain scored here at 1.
|
||||
let m = tree.match_prefix(None, &chain);
|
||||
assert_eq!(m.matched_blocks, 4);
|
||||
assert_eq!(m.workers, workers(&[&deep, &holed]));
|
||||
assert_eq!(m.workers(), workers(&[&deep, &holed]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -817,11 +1064,302 @@ mod tests {
|
||||
let tree = HashTree::new();
|
||||
let m = tree.match_prefix(None, &[]);
|
||||
assert_eq!(m.matched_blocks, 0);
|
||||
assert!(m.workers.is_empty());
|
||||
assert!(m.workers().is_empty());
|
||||
|
||||
let m2 = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m2.matched_blocks, 0);
|
||||
assert!(m2.workers.is_empty());
|
||||
assert!(m2.workers().is_empty());
|
||||
}
|
||||
|
||||
/// The write-back sequence the engine publishes for a backed-up block:
|
||||
/// device store, host store once the D2H copy lands, then a DEVICE-tagged
|
||||
/// removal when the device copy is evicted. The worker still holds the
|
||||
/// block on host, so it must stay an owner — just no longer a device one.
|
||||
/// Only the host-tagged removal ends ownership.
|
||||
#[test]
|
||||
fn device_eviction_after_host_backup_keeps_the_worker_as_host_owner() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
tree.insert_tiered(&a, None, &[1, 2, 3], Tiers::for_store(Some("GPU")));
|
||||
tree.insert_tiered(&a, None, &[1, 2, 3], Tiers::for_store(Some("CPU_PINNED")));
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
assert_eq!(m.device_workers(), workers(&[&a]), "held on device too");
|
||||
|
||||
tree.remove_tiered(&a, &[3], Tiers::for_remove(Some("GPU")));
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3, "host copy keeps the chain matchable");
|
||||
assert_eq!(
|
||||
m.workers(),
|
||||
workers(&[&a]),
|
||||
"host-only holder is still an owner"
|
||||
);
|
||||
assert!(
|
||||
m.device_workers().is_empty(),
|
||||
"but no longer a device owner"
|
||||
);
|
||||
|
||||
tree.remove_tiered(&a, &[3], Tiers::for_remove(Some("CPU_PINNED")));
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 2, "last tier gone: node pruned");
|
||||
}
|
||||
|
||||
/// The routing path reads `prefix_depths`, not `match_prefix`, so the
|
||||
/// tier fix has to show up there: a chain whose device copy was evicted
|
||||
/// after a host backup still scores at its full depth. The untagged
|
||||
/// removal is the control — it clears every tier and the depth collapses,
|
||||
/// which is what the tier-blind tree did for the tagged case too.
|
||||
#[test]
|
||||
fn prefix_depths_survive_a_device_eviction_with_a_host_backup() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
tree.insert_tiered(&a, None, &[1, 2, 3], Tiers::for_store(Some("GPU")));
|
||||
tree.insert_tiered(&a, None, &[1, 2, 3], Tiers::for_store(Some("CPU_PINNED")));
|
||||
|
||||
// Block 1 included on purpose. `prefix_depths` SEEDS its live set from
|
||||
// level 1 and narrows from there, so evicting only from block 2 onward
|
||||
// would leave that seed exercised against device-held state alone — and
|
||||
// a seed narrowed to device owners would send every host-only worker to
|
||||
// depth 0, restoring this bug with the suite still green.
|
||||
tree.remove_tiered(&a, &[1, 2, 3], Tiers::for_remove(Some("GPU")));
|
||||
assert_eq!(
|
||||
tree.prefix_depths(None, &[1, 2, 3]).get(&a).copied(),
|
||||
Some(3),
|
||||
"host backup keeps every level attributed to the worker",
|
||||
);
|
||||
|
||||
tree.remove_tiered(&a, &[1, 2, 3], Tiers::for_remove(None));
|
||||
assert_eq!(
|
||||
tree.prefix_depths(None, &[1, 2, 3]).get(&a).copied(),
|
||||
None,
|
||||
"an untagged removal still clears every tier, host backup included",
|
||||
);
|
||||
}
|
||||
|
||||
/// Untagged events keep the pre-tiering contract: a bare `BlockStored` is
|
||||
/// a device store, a bare `BlockRemoved` clears every tier at once. A
|
||||
/// publisher that never tags must see exactly the behaviour it always had.
|
||||
#[test]
|
||||
fn untagged_events_keep_the_legacy_meaning() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
tree.insert(&a, None, &[1, 2]);
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(
|
||||
m.device_workers(),
|
||||
workers(&[&a]),
|
||||
"untagged store is device"
|
||||
);
|
||||
|
||||
// Add a host copy, then an UNTAGGED removal: must clear both.
|
||||
tree.insert_tiered(&a, None, &[1, 2], Tiers::HOST);
|
||||
tree.remove(&a, &[2]);
|
||||
assert_eq!(tree.match_prefix(None, &[1, 2]).matched_blocks, 1);
|
||||
}
|
||||
|
||||
/// An unknown medium is asymmetric on purpose: the store is dropped (no
|
||||
/// tier at all), while a removal tagged with it clears every tier. Filing
|
||||
/// the store under a guess would make the worker a routing candidate on a
|
||||
/// tier this build cannot price; narrowing the removal to a bit that was
|
||||
/// never set would leave a stale owner forever.
|
||||
#[test]
|
||||
fn unknown_medium_drops_the_store_and_removes_everything() {
|
||||
assert_eq!(Tiers::for_store(Some("NVLINK_PEER")), Tiers::default());
|
||||
assert_eq!(Tiers::for_remove(Some("NVLINK_PEER")), Tiers::ALL);
|
||||
assert_eq!(Tiers::for_store(None), Tiers::DEVICE);
|
||||
assert_eq!(Tiers::for_remove(None), Tiers::ALL);
|
||||
assert_eq!(Tiers::for_store(Some("CPU_PINNED")), Tiers::HOST);
|
||||
assert_eq!(Tiers::for_store(Some("DISK")), Tiers::DISK);
|
||||
assert_eq!(Tiers::for_store(Some("EXTERNAL")), Tiers::EXTERNAL);
|
||||
// A known tag clears its own tier and nothing else.
|
||||
assert_eq!(Tiers::for_remove(Some("DISK")), Tiers::DISK);
|
||||
assert_eq!(Tiers::for_remove(Some("EXTERNAL")), Tiers::EXTERNAL);
|
||||
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
let b = worker("http://b", 0);
|
||||
tree.insert_tiered(&a, None, &[1], Tiers::DEVICE);
|
||||
tree.insert_tiered(&a, None, &[1], Tiers::HOST);
|
||||
tree.insert_tiered(&b, None, &[1], Tiers::for_store(Some("NVLINK_PEER")));
|
||||
let m = tree.match_prefix(None, &[1]);
|
||||
assert_eq!(
|
||||
m.workers(),
|
||||
workers(&[&a]),
|
||||
"a store on an unrankable tier must not make the worker an owner"
|
||||
);
|
||||
tree.remove_tiered(&a, &[1], Tiers::for_remove(Some("NVLINK_PEER")));
|
||||
assert_eq!(
|
||||
tree.match_prefix(None, &[1]).matched_blocks,
|
||||
0,
|
||||
"an unknown-medium removal clears every tier the worker held",
|
||||
);
|
||||
}
|
||||
|
||||
/// Tiers are per (node, worker): a device removal by one worker must not
|
||||
/// touch another worker's hold on the same node, and a node whose
|
||||
/// carriers differ by tier reports the device subset exactly.
|
||||
#[test]
|
||||
fn tiers_are_tracked_per_worker() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
let b = worker("http://b", 0);
|
||||
tree.insert_tiered(&a, None, &[1, 2], Tiers::DEVICE);
|
||||
tree.insert_tiered(&b, None, &[1, 2], Tiers::HOST);
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(m.workers(), workers(&[&a, &b]));
|
||||
assert_eq!(m.device_workers(), workers(&[&a]));
|
||||
|
||||
tree.remove_tiered(&a, &[2], Tiers::DEVICE);
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(m.workers(), workers(&[&b]), "a dropped, b untouched");
|
||||
assert!(m.device_workers().is_empty());
|
||||
}
|
||||
|
||||
/// A store on no tier must not create a carrier: `remove` relies on
|
||||
/// "no bits ⇒ no entry" to know when a node is prunable.
|
||||
#[test]
|
||||
fn empty_tier_insert_is_noop() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
tree.insert_tiered(&a, None, &[1], Tiers::default());
|
||||
assert_eq!(tree.node_count(), 0);
|
||||
}
|
||||
|
||||
/// `DISK` (L3) and `EXTERNAL` (L4) are distinct tiers in the engine's own
|
||||
/// `StorageMedium`, so they must not share a bit: on a fleet running both,
|
||||
/// folding them would make an L3 eviction erase the router's knowledge of
|
||||
/// the L4 copy.
|
||||
#[test]
|
||||
fn disk_and_external_are_independent_tiers() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
tree.insert_tiered(&a, None, &[1], Tiers::for_store(Some("EXTERNAL")));
|
||||
tree.insert_tiered(&a, None, &[1], Tiers::for_store(Some("DISK")));
|
||||
|
||||
tree.remove_tiered(&a, &[1], Tiers::for_remove(Some("DISK")));
|
||||
assert_eq!(
|
||||
tree.match_prefix(None, &[1]).workers(),
|
||||
workers(&[&a]),
|
||||
"an L3 eviction must not take the L4 copy with it",
|
||||
);
|
||||
|
||||
tree.remove_tiered(&a, &[1], Tiers::for_remove(Some("EXTERNAL")));
|
||||
assert_eq!(tree.match_prefix(None, &[1]).matched_blocks, 0);
|
||||
}
|
||||
|
||||
/// `remove` and `prune_cascade` rely on "no bits ⇒ no entry" to know
|
||||
/// when a node is prunable, and every mutation site has to preserve it —
|
||||
/// exactly the shape that rots under a later refactor. A deterministic
|
||||
/// random walk over every operation and every medium, with the invariant
|
||||
/// asserted after EVERY step.
|
||||
#[test]
|
||||
fn carrier_invariant_holds_under_a_random_walk() {
|
||||
// xorshift64*, so the walk is reproducible without a dev-dependency.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA: [Option<&str>; 6] = [
|
||||
Some("GPU"),
|
||||
Some("CPU_PINNED"),
|
||||
Some("DISK"),
|
||||
Some("EXTERNAL"),
|
||||
Some("NVLINK_PEER"),
|
||||
None,
|
||||
];
|
||||
|
||||
for seed in 1..=16u64 {
|
||||
let tree = HashTree::new();
|
||||
let ws: Vec<KvWorkerId> = (0..4)
|
||||
.map(|i| worker(&format!("http://w{i}"), i % 2))
|
||||
.collect();
|
||||
let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
|
||||
|
||||
for step in 0..400 {
|
||||
let w = &ws[rng.below(ws.len() as u64) as usize];
|
||||
let medium = MEDIA[rng.below(MEDIA.len() as u64) as usize];
|
||||
// A small hash space so chains collide and share nodes.
|
||||
let hashes: Vec<i64> = (0..1 + rng.below(4))
|
||||
.map(|_| rng.below(12) as i64)
|
||||
.collect();
|
||||
// Sometimes anchor to a hash that may or may not be in the
|
||||
// tree, to exercise `resolve_parent`'s fallbacks.
|
||||
let parent = (rng.below(4) == 0).then(|| rng.below(12) as i64);
|
||||
|
||||
match rng.below(16) {
|
||||
0 => tree.clear_worker(w),
|
||||
1 => {
|
||||
tree.evict_lru(rng.below(20) as usize);
|
||||
}
|
||||
2..=6 => tree.remove_tiered(w, &hashes, Tiers::for_remove(medium)),
|
||||
_ => tree.insert_tiered(w, parent, &hashes, Tiers::for_store(medium)),
|
||||
}
|
||||
|
||||
assert!(
|
||||
tree.debug_no_empty_carrier(),
|
||||
"seed {seed} step {step}: a carrier is present holding no tier",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AllBlocksCleared` is the pod-restart / scale-down path
|
||||
/// (`remove_worker` clears every rank), so it must drop a carrier
|
||||
/// regardless of which tiers it held — a hold on any lower tier is still
|
||||
/// a hold.
|
||||
#[test]
|
||||
fn clear_worker_drops_carriers_on_every_tier() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
let b = worker("http://b", 0);
|
||||
tree.insert_tiered(&a, None, &[1, 2], Tiers::HOST);
|
||||
tree.insert_tiered(&a, None, &[3], Tiers::for_store(Some("EXTERNAL")));
|
||||
tree.insert_tiered(&b, None, &[1, 2], Tiers::DEVICE);
|
||||
|
||||
tree.clear_worker(&a);
|
||||
assert_eq!(
|
||||
tree.match_prefix(None, &[1, 2]).workers(),
|
||||
workers(&[&b]),
|
||||
"a host-only carrier must be cleared like any other",
|
||||
);
|
||||
assert_eq!(tree.match_prefix(None, &[3]).matched_blocks, 0);
|
||||
}
|
||||
|
||||
/// `resolve_parent` disambiguates a shared hash by preferring a candidate
|
||||
/// the worker already holds — on ANY tier. Narrowing that to device owners
|
||||
/// would re-anchor a continuation at the root once the worker's device
|
||||
/// copy was evicted, fragmenting the very prefix the host tier still
|
||||
/// serves. The shape is a shared system prompt whose block hash also
|
||||
/// appears in another chain.
|
||||
#[test]
|
||||
fn ambiguous_parent_resolves_through_a_host_only_hold() {
|
||||
let tree = HashTree::new();
|
||||
let a = worker("http://a", 0);
|
||||
let b = worker("http://b", 0);
|
||||
tree.insert_tiered(&a, None, &[5, 7], Tiers::DEVICE);
|
||||
tree.insert_tiered(&a, None, &[5, 7], Tiers::HOST);
|
||||
tree.remove_tiered(&a, &[5, 7], Tiers::for_remove(Some("GPU")));
|
||||
// A second chain carrying hash 7, so `parent_hash = 7` is ambiguous.
|
||||
tree.insert_tiered(&b, None, &[6, 7], Tiers::DEVICE);
|
||||
|
||||
tree.insert_tiered(&a, Some(7), &[8], Tiers::DEVICE);
|
||||
assert_eq!(
|
||||
tree.prefix_depths(None, &[5, 7, 8]).get(&a).copied(),
|
||||
Some(3),
|
||||
"the continuation must attach under the host-only hold, not at root",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -832,21 +1370,21 @@ mod tests {
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
// Diverges at depth 3 (input asks for 4, tree has 3).
|
||||
let m = tree.match_prefix(None, &[1, 2, 4]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
// No match at root.
|
||||
let m = tree.match_prefix(None, &[9, 9]);
|
||||
assert_eq!(m.matched_blocks, 0);
|
||||
assert!(m.workers.is_empty());
|
||||
assert!(m.workers().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -860,16 +1398,16 @@ mod tests {
|
||||
// Common prefix node carries both.
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&a, &b]));
|
||||
assert_eq!(m.workers(), workers(&[&a, &b]));
|
||||
|
||||
// Divergent leaf carries only the matching worker.
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2, 4]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&b]));
|
||||
assert_eq!(m.workers(), workers(&[&b]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -881,7 +1419,7 @@ mod tests {
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -902,12 +1440,12 @@ mod tests {
|
||||
// Match length 2 lands on node 2 (workers empty), so workers={}.
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert!(m.workers.is_empty());
|
||||
assert!(m.workers().is_empty());
|
||||
|
||||
// Match length 3 lands on node 3 (workers still has A).
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
// Reverse-index sanity for hash 2: still present (node holds it).
|
||||
{
|
||||
@@ -932,13 +1470,13 @@ mod tests {
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
// Node 3 was pruned, so only 2 levels match.
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&b]));
|
||||
assert_eq!(m.workers(), workers(&[&b]));
|
||||
|
||||
// [1,2] now has only B (A was the only other holder of node 2;
|
||||
// wait — actually A held 1 and 2 too. But B also holds 1 and 2.)
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&b]));
|
||||
assert_eq!(m.workers(), workers(&[&b]));
|
||||
|
||||
// Node count: root + 1 + 2 + 4 (no 3) = 3 non-root nodes.
|
||||
assert_eq!(tree.node_count(), 3);
|
||||
@@ -986,11 +1524,11 @@ mod tests {
|
||||
// Both chains exist independently.
|
||||
let m = tree.match_prefix(None, &[1, 5]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
let m = tree.match_prefix(None, &[2, 5]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
// Reverse index for hash 5 has 2 distinct nodes.
|
||||
{
|
||||
@@ -1005,10 +1543,10 @@ mod tests {
|
||||
assert_eq!(tree.node_count(), 2);
|
||||
let m = tree.match_prefix(None, &[1, 5]);
|
||||
assert_eq!(m.matched_blocks, 1);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
let m = tree.match_prefix(None, &[2, 5]);
|
||||
assert_eq!(m.matched_blocks, 1);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1022,16 +1560,16 @@ mod tests {
|
||||
// Common prefix has both ranks.
|
||||
let m = tree.match_prefix(None, &[1, 2]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&w0, &w1]));
|
||||
assert_eq!(m.workers(), workers(&[&w0, &w1]));
|
||||
|
||||
// Divergent leaves: each rank on its own.
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&w0]));
|
||||
assert_eq!(m.workers(), workers(&[&w0]));
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2, 4]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&w1]));
|
||||
assert_eq!(m.workers(), workers(&[&w1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1049,12 +1587,12 @@ mod tests {
|
||||
// The chain 1->5->7 must exist with A.
|
||||
let m = tree.match_prefix(None, &[1, 5, 7]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
// The chain 2->5 should NOT have a 7-child (we routed to A's branch).
|
||||
let m = tree.match_prefix(None, &[2, 5, 7]);
|
||||
assert_eq!(m.matched_blocks, 2);
|
||||
assert_eq!(m.workers, workers(&[&b]));
|
||||
assert_eq!(m.workers(), workers(&[&b]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1073,7 +1611,7 @@ mod tests {
|
||||
// C is reachable as a fresh root child at hash=9.
|
||||
let m = tree.match_prefix(None, &[9]);
|
||||
assert_eq!(m.matched_blocks, 1);
|
||||
assert_eq!(m.workers, workers(&[&c]));
|
||||
assert_eq!(m.workers(), workers(&[&c]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1086,7 +1624,7 @@ mod tests {
|
||||
assert_eq!(tree.node_count(), 3);
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1158,7 +1696,7 @@ mod tests {
|
||||
// The newer chain should still match fully.
|
||||
let m = tree.match_prefix(None, &[200, 201, 202]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1171,7 +1709,7 @@ mod tests {
|
||||
|
||||
let m = tree.match_prefix(None, &[10, 20, 30]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&a]));
|
||||
assert_eq!(m.workers(), workers(&[&a]));
|
||||
|
||||
// Confirm parent_block_hash chain: node carrying 30 should record
|
||||
// parent_block_hash = Some(20).
|
||||
@@ -1199,6 +1737,6 @@ mod tests {
|
||||
|
||||
let m = tree.match_prefix(None, &[1, 2, 3]);
|
||||
assert_eq!(m.matched_blocks, 3);
|
||||
assert_eq!(m.workers, workers(&[&b]));
|
||||
assert_eq!(m.workers(), workers(&[&b]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +155,9 @@ fn match_prefix_is_consistent_with_concurrent_clear() {
|
||||
// consistent.
|
||||
if m.matched_blocks == chain.len() {
|
||||
assert!(
|
||||
m.workers.contains(&w),
|
||||
m.workers().contains(&w),
|
||||
"full match must include worker; got {:?}",
|
||||
m.workers,
|
||||
m.workers(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
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);
|
||||
&& 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 Radix Tree provider reads to
|
||||
@@ -96,7 +96,8 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
"subscribers disagreed on matched_blocks",
|
||||
);
|
||||
assert_eq!(
|
||||
ma.workers, mb.workers,
|
||||
ma.workers(),
|
||||
mb.workers(),
|
||||
"subscribers disagreed on worker set",
|
||||
);
|
||||
break;
|
||||
@@ -106,7 +107,10 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
"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,
|
||||
ma.matched_blocks,
|
||||
ma.workers(),
|
||||
mb.matched_blocks,
|
||||
mb.workers(),
|
||||
);
|
||||
}
|
||||
sequence += 1;
|
||||
@@ -249,34 +253,34 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
&& 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);
|
||||
&& 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),
|
||||
!ax.workers().contains(&key_y),
|
||||
"router_a cross-attributed worker_y to prefix X: {:?}",
|
||||
ax.workers,
|
||||
ax.workers(),
|
||||
);
|
||||
assert!(
|
||||
!ay.workers.contains(&key_x),
|
||||
!ay.workers().contains(&key_x),
|
||||
"router_a cross-attributed worker_x to prefix Y: {:?}",
|
||||
ay.workers,
|
||||
ay.workers(),
|
||||
);
|
||||
assert!(
|
||||
!bx.workers.contains(&key_y),
|
||||
!bx.workers().contains(&key_y),
|
||||
"router_b cross-attributed worker_y to prefix X: {:?}",
|
||||
bx.workers,
|
||||
bx.workers(),
|
||||
);
|
||||
assert!(
|
||||
!by.workers.contains(&key_x),
|
||||
!by.workers().contains(&key_x),
|
||||
"router_b cross-attributed worker_x to prefix Y: {:?}",
|
||||
by.workers,
|
||||
by.workers(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -287,13 +291,13 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
router_b: X={{matched={}, workers={:?}}}, Y={{matched={}, workers={:?}}}\n \
|
||||
targets: X={target_x}, Y={target_y}",
|
||||
ax.matched_blocks,
|
||||
ax.workers,
|
||||
ax.workers(),
|
||||
ay.matched_blocks,
|
||||
ay.workers,
|
||||
ay.workers(),
|
||||
bx.matched_blocks,
|
||||
bx.workers,
|
||||
bx.workers(),
|
||||
by.matched_blocks,
|
||||
by.workers,
|
||||
by.workers(),
|
||||
);
|
||||
}
|
||||
sequence += 1;
|
||||
|
||||
Reference in New Issue
Block a user