use crate::ids::StationId;
use crate::spatial::CellCoord3;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CellLoadSample {
pub cell: CellCoord3,
pub owned_entities: usize,
pub ghost_entities: usize,
pub subscribers: usize,
pub estimated_updates: usize,
pub estimated_bytes: usize,
pub tick_cost_units: u64,
pub event_pressure: usize,
}
impl CellLoadSample {
pub fn pressure_score(self) -> u64 {
let entities = (self.owned_entities + self.ghost_entities) as u64;
let subscribers = self.subscribers as u64;
let updates = self.estimated_updates as u64;
let bytes = (self.estimated_bytes / 256) as u64;
let events = self.event_pressure as u64;
entities
.saturating_mul(8)
.saturating_add(subscribers.saturating_mul(4))
.saturating_add(updates.saturating_mul(2))
.saturating_add(bytes)
.saturating_add(events.saturating_mul(16))
.saturating_add(self.tick_cost_units)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StationLoadSample {
pub station_id: StationId,
pub owned_entities: usize,
pub ghost_entities: usize,
pub subscribers: usize,
pub queued_events: usize,
pub estimated_bytes: usize,
pub tick_cost_units: u64,
pub cells: Vec<CellLoadSample>,
}
impl StationLoadSample {
pub const fn total_entities(&self) -> usize {
self.owned_entities + self.ghost_entities
}
pub fn max_cell_pressure(&self) -> u64 {
self.cells
.iter()
.map(|cell| cell.pressure_score())
.max()
.unwrap_or(0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HotspotThresholds {
pub max_station_entities: usize,
pub max_station_subscribers: usize,
pub max_queued_events: usize,
pub max_estimated_bytes: usize,
pub max_tick_cost_units: u64,
pub max_cell_pressure: u64,
}
impl Default for HotspotThresholds {
fn default() -> Self {
Self {
max_station_entities: 50_000,
max_station_subscribers: 2_000,
max_queued_events: 10_000,
max_estimated_bytes: 64 * 1024 * 1024,
max_tick_cost_units: 16_000,
max_cell_pressure: 10_000,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HotspotSeverity {
Normal,
Warm,
Hot,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HotspotDecision {
pub station_id: StationId,
pub severity: HotspotSeverity,
pub score: u64,
pub reasons: Vec<&'static str>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SplitProposal {
pub source_station: StationId,
pub cells_to_move: Vec<CellCoord3>,
pub moved_pressure_score: u64,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct HotspotPlanner;
impl HotspotPlanner {
pub fn evaluate(sample: &StationLoadSample, thresholds: HotspotThresholds) -> HotspotDecision {
let mut score = 0_u64;
let mut reasons = Vec::new();
if sample.total_entities() > thresholds.max_station_entities {
score = score.saturating_add(1);
reasons.push("station_entities");
}
if sample.subscribers > thresholds.max_station_subscribers {
score = score.saturating_add(1);
reasons.push("station_subscribers");
}
if sample.queued_events > thresholds.max_queued_events {
score = score.saturating_add(1);
reasons.push("queued_events");
}
if sample.estimated_bytes > thresholds.max_estimated_bytes {
score = score.saturating_add(1);
reasons.push("estimated_bytes");
}
if sample.tick_cost_units > thresholds.max_tick_cost_units {
score = score.saturating_add(1);
reasons.push("tick_cost");
}
if sample.max_cell_pressure() > thresholds.max_cell_pressure {
score = score.saturating_add(1);
reasons.push("cell_pressure");
}
let severity = match score {
0 => HotspotSeverity::Normal,
1 => HotspotSeverity::Warm,
_ => HotspotSeverity::Hot,
};
HotspotDecision {
station_id: sample.station_id,
severity,
score,
reasons,
}
}
pub fn propose_cell_split(
sample: &StationLoadSample,
max_cells_to_move: usize,
) -> SplitProposal {
let mut cells = sample.cells.clone();
cells.sort_by(|left, right| {
right
.pressure_score()
.cmp(&left.pressure_score())
.then_with(|| left.cell.cmp(&right.cell))
});
let mut proposal = SplitProposal {
source_station: sample.station_id,
cells_to_move: Vec::with_capacity(max_cells_to_move.min(cells.len())),
moved_pressure_score: 0,
};
for cell in cells.into_iter().take(max_cells_to_move) {
proposal.moved_pressure_score = proposal
.moved_pressure_score
.saturating_add(cell.pressure_score());
proposal.cells_to_move.push(cell.cell);
}
proposal
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planner_detects_hot_station_and_suggests_hottest_cells() {
let sample = StationLoadSample {
station_id: StationId::new(3),
owned_entities: 100,
subscribers: 20,
cells: vec![
CellLoadSample {
cell: CellCoord3::new(0, 0, 0),
owned_entities: 1,
..CellLoadSample::default()
},
CellLoadSample {
cell: CellCoord3::new(1, 0, 0),
owned_entities: 100,
event_pressure: 50,
..CellLoadSample::default()
},
],
..StationLoadSample::default()
};
let thresholds = HotspotThresholds {
max_station_entities: 50,
max_cell_pressure: 10,
..HotspotThresholds::default()
};
let decision = HotspotPlanner::evaluate(&sample, thresholds);
assert_eq!(decision.severity, HotspotSeverity::Hot);
let proposal = HotspotPlanner::propose_cell_split(&sample, 1);
assert_eq!(proposal.cells_to_move, vec![CellCoord3::new(1, 0, 0)]);
}
}