use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::RwLock;
use super::event::{ChainId, NodeId};
pub trait PlacementScorer: Send + Sync {
fn score(&self, chain: ChainId, node: NodeId) -> Option<f32>;
fn live_score(&self, chain: ChainId, node: NodeId) -> Option<f32> {
self.score(chain, node)
}
fn best_alternative(&self, chain: ChainId, exclude: &[NodeId]) -> Option<(NodeId, f32)>;
fn node_fingerprint(&self, node: NodeId) -> Option<u64> {
let _ = node;
None
}
fn migration_cost(&self, chain: ChainId, target: NodeId) -> Option<MigrationCost> {
let _ = (chain, target);
None
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct SchedulerConfig {
pub score_floor: f32,
pub hysteresis_gap: f32,
pub cooldown: Duration,
pub decision_interval: Duration,
pub cost_model: MigrationCostModel,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
score_floor: 0.5,
hysteresis_gap: 0.2,
cooldown: Duration::from_secs(5 * 60),
decision_interval: Duration::from_secs(30),
cost_model: MigrationCostModel::default(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MigrationCost {
pub state_transfer: Duration,
pub disruption: Duration,
pub reliability_factor: f32,
}
impl Default for MigrationCost {
fn default() -> Self {
Self {
state_transfer: Duration::ZERO,
disruption: Duration::ZERO,
reliability_factor: 1.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MigrationCostModel {
pub cost_per_sec: f32,
}
impl Default for MigrationCostModel {
fn default() -> Self {
Self { cost_per_sec: 0.1 }
}
}
impl MigrationCostModel {
pub fn score_equivalent(&self, cost: &MigrationCost) -> f32 {
let secs = cost.state_transfer.as_secs_f32() + cost.disruption.as_secs_f32();
let weight = if cost.reliability_factor.is_finite() && cost.reliability_factor > 0.0 {
cost.reliability_factor
} else {
1.0
};
self.cost_per_sec * secs * weight
}
}
#[derive(Clone, Default)]
pub struct SchedulerRegistry {
inner: Arc<RwLock<Option<Arc<dyn PlacementScorer>>>>,
}
impl SchedulerRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn install(&self, scorer: Arc<dyn PlacementScorer>) -> Option<Arc<dyn PlacementScorer>> {
let mut guard = self.inner.write();
guard.replace(scorer)
}
pub fn current(&self) -> Option<Arc<dyn PlacementScorer>> {
self.inner.read().clone()
}
pub fn has_scorer(&self) -> bool {
self.inner.read().is_some()
}
}
#[derive(Clone, Default, Debug)]
pub struct ScoreSnapshot {
scores: HashMap<ChainId, HashMap<NodeId, f32>>,
}
impl ScoreSnapshot {
#[cfg(test)]
pub(crate) fn new() -> Self {
Self::default()
}
pub fn get(&self, chain: ChainId, node: NodeId) -> Option<f32> {
self.scores.get(&chain).and_then(|m| m.get(&node)).copied()
}
#[cfg(test)]
pub(crate) fn insert(&mut self, chain: ChainId, node: NodeId, score: f32) {
self.scores.entry(chain).or_default().insert(node, score);
}
pub fn len(&self) -> usize {
self.scores.values().map(HashMap::len).sum()
}
pub fn is_empty(&self) -> bool {
self.scores.values().all(HashMap::is_empty)
}
fn set_chain(&mut self, chain: ChainId, holders: HashMap<NodeId, f32>) {
self.scores.insert(chain, holders);
}
fn retain_chains(&mut self, keep: impl Fn(ChainId) -> bool) {
self.scores.retain(|&chain, _| keep(chain));
}
}
pub struct SnapshotScorer<'a> {
snapshot: &'a ScoreSnapshot,
live: &'a dyn PlacementScorer,
}
impl<'a> SnapshotScorer<'a> {
pub fn new(snapshot: &'a ScoreSnapshot, live: &'a dyn PlacementScorer) -> Self {
Self { snapshot, live }
}
}
impl PlacementScorer for SnapshotScorer<'_> {
fn score(&self, chain: ChainId, node: NodeId) -> Option<f32> {
self.snapshot.get(chain, node)
}
fn live_score(&self, chain: ChainId, node: NodeId) -> Option<f32> {
self.live.score(chain, node)
}
fn best_alternative(&self, chain: ChainId, exclude: &[NodeId]) -> Option<(NodeId, f32)> {
self.live.best_alternative(chain, exclude)
}
fn node_fingerprint(&self, node: NodeId) -> Option<u64> {
self.live.node_fingerprint(node)
}
fn migration_cost(&self, chain: ChainId, target: NodeId) -> Option<MigrationCost> {
self.live.migration_cost(chain, target)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Trend {
#[default]
Stable,
Degrading,
Improving,
}
#[derive(Clone, Debug)]
pub struct ScoreHistory {
recent: VecDeque<(Instant, f32)>,
current: f32,
ewma: f32,
trend: Trend,
}
const HISTORY_CAP: usize = 64;
const EWMA_ALPHA: f32 = 0.3;
const TREND_EPS: f32 = 0.02;
impl ScoreHistory {
fn new(now: Instant, score: f32) -> Self {
let mut recent = VecDeque::with_capacity(HISTORY_CAP);
recent.push_back((now, score));
Self {
recent,
current: score,
ewma: score,
trend: Trend::Stable,
}
}
fn record(&mut self, now: Instant, score: f32) {
let prev_ewma = self.ewma;
self.trend = if score < prev_ewma - TREND_EPS {
Trend::Degrading
} else if score > prev_ewma + TREND_EPS {
Trend::Improving
} else {
Trend::Stable
};
self.ewma = EWMA_ALPHA * score + (1.0 - EWMA_ALPHA) * self.ewma;
self.current = score;
if self.recent.len() == HISTORY_CAP {
self.recent.pop_front();
}
self.recent.push_back((now, score));
}
pub fn current(&self) -> f32 {
self.current
}
pub fn trend(&self) -> Trend {
self.trend
}
pub fn len(&self) -> usize {
self.recent.len()
}
pub fn is_empty(&self) -> bool {
self.recent.is_empty()
}
}
#[derive(Default)]
pub struct LocalScheduler {
current: ScoreSnapshot,
history: HashMap<ChainId, ScoreHistory>,
last_fingerprint: HashMap<ChainId, u64>,
last_sampled: HashMap<ChainId, Instant>,
}
impl LocalScheduler {
pub fn new() -> Self {
Self::default()
}
pub fn sample(
&mut self,
replicas: &HashMap<ChainId, BTreeSet<NodeId>>,
replica_leader: &HashMap<ChainId, NodeId>,
this_node: NodeId,
scorer: &dyn PlacementScorer,
now: Instant,
decision_interval: Duration,
) -> &ScoreSnapshot {
let mut led: HashSet<ChainId> = HashSet::new();
for (&chain, holders) in replicas {
if replica_leader.get(&chain).copied() != Some(this_node) {
continue;
}
if holders.is_empty() {
continue;
}
led.insert(chain);
let fingerprint = Self::chain_fingerprint(scorer, holders);
let backstop_due = self
.last_sampled
.get(&chain)
.is_none_or(|t| now.saturating_duration_since(*t) >= decision_interval);
let dirty = match fingerprint {
None => true,
Some(fp) => self.last_fingerprint.get(&chain).copied() != Some(fp),
};
if !(dirty || backstop_due) {
continue;
}
let mut holder_scores: HashMap<NodeId, f32> = HashMap::new();
let mut worst: Option<f32> = None;
for &h in holders {
if let Some(s) = scorer.score(chain, h) {
if s.is_nan() {
continue;
}
holder_scores.insert(h, s);
worst = Some(worst.map_or(s, |w| w.min(s)));
}
}
self.current.set_chain(chain, holder_scores);
if let Some(w) = worst {
self.history
.entry(chain)
.and_modify(|h| h.record(now, w))
.or_insert_with(|| ScoreHistory::new(now, w));
}
match fingerprint {
Some(fp) => {
self.last_fingerprint.insert(chain, fp);
}
None => {
self.last_fingerprint.remove(&chain);
}
}
self.last_sampled.insert(chain, now);
}
if self.last_sampled.len() != led.len() {
self.current.retain_chains(|c| led.contains(&c));
self.history.retain(|c, _| led.contains(c));
self.last_fingerprint.retain(|c, _| led.contains(c));
self.last_sampled.retain(|c, _| led.contains(c));
}
&self.current
}
fn chain_fingerprint(scorer: &dyn PlacementScorer, holders: &BTreeSet<NodeId>) -> Option<u64> {
use super::super::hash::{fnv1a_step, FNV1A_OFFSET};
let mut acc = FNV1A_OFFSET;
for &h in holders {
let fp = scorer.node_fingerprint(h)?;
acc = fnv1a_step(acc, h);
acc = fnv1a_step(acc, fp);
}
Some(acc)
}
pub fn history(&self, chain: ChainId) -> Option<&ScoreHistory> {
self.history.get(&chain)
}
pub fn tracked_len(&self) -> usize {
self.history.len()
}
}
#[cfg(test)]
pub(crate) struct FixedScorer {
pub scores: HashMap<(ChainId, NodeId), f32>,
pub alternatives: HashMap<ChainId, (NodeId, f32)>,
}
#[cfg(test)]
impl PlacementScorer for FixedScorer {
fn score(&self, chain: ChainId, node: NodeId) -> Option<f32> {
self.scores.get(&(chain, node)).copied()
}
fn best_alternative(&self, chain: ChainId, exclude: &[NodeId]) -> Option<(NodeId, f32)> {
let (n, s) = self.alternatives.get(&chain).copied()?;
if exclude.contains(&n) {
None
} else {
Some((n, s))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn fixed_scorer_returns_table_entries() {
let mut scorer = FixedScorer {
scores: HashMap::new(),
alternatives: HashMap::new(),
};
scorer.scores.insert((1, 100), 0.4);
scorer.alternatives.insert(1, (200, 0.9));
assert_eq!(scorer.score(1, 100), Some(0.4));
assert_eq!(scorer.score(1, 999), None);
assert_eq!(scorer.best_alternative(1, &[]), Some((200, 0.9)));
assert_eq!(scorer.best_alternative(1, &[200]), None);
}
#[test]
fn registry_install_replaces_and_returns_prior() {
let reg = SchedulerRegistry::new();
assert!(!reg.has_scorer());
let s1 = Arc::new(FixedScorer {
scores: HashMap::new(),
alternatives: HashMap::new(),
});
let prior = reg.install(Arc::clone(&s1) as Arc<dyn PlacementScorer>);
assert!(prior.is_none());
assert!(reg.has_scorer());
let s2 = Arc::new(FixedScorer {
scores: HashMap::new(),
alternatives: HashMap::new(),
});
let prior2 = reg.install(s2 as Arc<dyn PlacementScorer>);
assert!(prior2.is_some());
}
#[test]
fn scheduler_config_defaults_match_the_plan() {
let cfg = SchedulerConfig::default();
assert!((cfg.score_floor - 0.5).abs() < 1e-6);
assert!((cfg.hysteresis_gap - 0.2).abs() < 1e-6);
assert_eq!(cfg.cooldown, Duration::from_secs(5 * 60));
assert_eq!(cfg.decision_interval, Duration::from_secs(30));
}
#[test]
fn score_snapshot_get_insert() {
let mut s = ScoreSnapshot::new();
assert!(s.is_empty());
s.insert(7, 70, 0.5);
assert_eq!(s.get(7, 70), Some(0.5));
assert_eq!(s.get(7, 71), None);
assert_eq!(s.len(), 1);
assert!(!s.is_empty());
}
#[test]
fn snapshot_scorer_reads_snapshot_and_delegates_alternative() {
let mut snap = ScoreSnapshot::new();
snap.insert(1, 100, 0.42);
let live = FixedScorer {
scores: [((1, 100), 0.99)].into_iter().collect(),
alternatives: [(1, (200, 0.9))].into_iter().collect(),
};
let ss = SnapshotScorer::new(&snap, &live);
assert_eq!(
ss.score(1, 100),
Some(0.42),
"score comes from the snapshot"
);
assert_eq!(ss.score(1, 999), None, "unsampled holder reads None");
assert_eq!(
ss.best_alternative(1, &[]),
Some((200, 0.9)),
"best_alternative delegates to the live scorer",
);
assert_eq!(ss.best_alternative(1, &[200]), None);
}
#[test]
fn score_history_bounds_to_cap() {
let now = Instant::now();
let mut h = ScoreHistory::new(now, 0.5);
for _ in 0..(HISTORY_CAP * 2) {
h.record(now, 0.5);
}
assert_eq!(h.len(), HISTORY_CAP, "ring is bounded by HISTORY_CAP");
}
#[test]
fn score_history_trend_transitions() {
let now = Instant::now();
let mut h = ScoreHistory::new(now, 0.8);
h.record(now, 0.2); assert_eq!(h.trend(), Trend::Degrading);
for _ in 0..5 {
h.record(now, 0.9); }
assert_eq!(h.trend(), Trend::Improving);
for _ in 0..30 {
h.record(now, 0.9); }
assert_eq!(h.trend(), Trend::Stable);
assert!((h.current() - 0.9).abs() < 1e-6);
}
#[test]
fn local_scheduler_samples_only_led_chains_and_tracks_worst() {
let this: NodeId = 100;
let mut replicas: HashMap<ChainId, BTreeSet<NodeId>> = HashMap::new();
replicas.insert(1, BTreeSet::from([100, 200]));
replicas.insert(2, BTreeSet::from([100]));
let mut leader: HashMap<ChainId, NodeId> = HashMap::new();
leader.insert(1, this); leader.insert(2, 999); let scorer = FixedScorer {
scores: [((1, 100), 0.4), ((1, 200), 0.6), ((2, 100), 0.9)]
.into_iter()
.collect(),
alternatives: HashMap::new(),
};
let mut ls = LocalScheduler::new();
let snap = ls.sample(
&replicas,
&leader,
this,
&scorer,
Instant::now(),
Duration::from_secs(30),
);
assert_eq!(snap.get(1, 100), Some(0.4));
assert_eq!(snap.get(1, 200), Some(0.6));
assert_eq!(snap.get(2, 100), None);
assert_eq!(ls.tracked_len(), 1);
assert!((ls.history(1).unwrap().current() - 0.4).abs() < 1e-6);
}
#[test]
fn local_scheduler_gcs_history_for_chains_no_longer_led() {
let this: NodeId = 100;
let scorer = FixedScorer {
scores: [((1, 100), 0.4), ((2, 100), 0.5)].into_iter().collect(),
alternatives: HashMap::new(),
};
let mut ls = LocalScheduler::new();
let mut replicas: HashMap<ChainId, BTreeSet<NodeId>> = HashMap::new();
replicas.insert(1, BTreeSet::from([100]));
replicas.insert(2, BTreeSet::from([100]));
let mut leader: HashMap<ChainId, NodeId> = HashMap::new();
leader.insert(1, this);
leader.insert(2, this);
let interval = Duration::from_secs(30);
ls.sample(&replicas, &leader, this, &scorer, Instant::now(), interval);
assert_eq!(ls.tracked_len(), 2);
leader.insert(2, 999);
ls.sample(&replicas, &leader, this, &scorer, Instant::now(), interval);
assert_eq!(ls.tracked_len(), 1);
assert!(ls.history(2).is_none(), "dropped chain is GC'd");
assert!(ls.history(1).is_some());
}
#[test]
fn losing_leadership_gcs_snapshot_too_not_just_history() {
let this: NodeId = 100;
let scorer = FixedScorer {
scores: [((1, 100), 0.4), ((2, 100), 0.5)].into_iter().collect(),
alternatives: HashMap::new(),
};
let mut ls = LocalScheduler::new();
let mut replicas: HashMap<ChainId, BTreeSet<NodeId>> = HashMap::new();
replicas.insert(1, BTreeSet::from([100]));
replicas.insert(2, BTreeSet::from([100]));
let mut leader: HashMap<ChainId, NodeId> = HashMap::new();
leader.insert(1, this);
leader.insert(2, this);
let interval = Duration::from_secs(30);
let t0 = Instant::now();
let snap = ls.sample(&replicas, &leader, this, &scorer, t0, interval);
assert_eq!(snap.get(2, 100), Some(0.5));
leader.insert(2, 999);
let snap = ls.sample(
&replicas,
&leader,
this,
&scorer,
t0 + Duration::from_secs(1),
interval,
);
assert_eq!(
snap.get(2, 100),
None,
"dropped chain's snapshot entry is GC'd"
);
assert_eq!(snap.get(1, 100), Some(0.4), "still-led chain is retained");
assert!(ls.history(2).is_none());
}
struct CountingScorer {
score: f32,
fp_present: std::sync::atomic::AtomicBool,
fp_bits: std::sync::atomic::AtomicU64,
calls: std::sync::atomic::AtomicUsize,
}
impl CountingScorer {
fn new(score: f32, fp: Option<u64>) -> Self {
use std::sync::atomic::*;
Self {
score,
fp_present: AtomicBool::new(fp.is_some()),
fp_bits: AtomicU64::new(fp.unwrap_or(0)),
calls: AtomicUsize::new(0),
}
}
fn calls(&self) -> usize {
self.calls.load(std::sync::atomic::Ordering::Relaxed)
}
fn set_fp(&self, fp: u64) {
self.fp_bits.store(fp, std::sync::atomic::Ordering::Relaxed);
}
}
impl PlacementScorer for CountingScorer {
fn score(&self, _chain: ChainId, _node: NodeId) -> Option<f32> {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Some(self.score)
}
fn best_alternative(&self, _chain: ChainId, _exclude: &[NodeId]) -> Option<(NodeId, f32)> {
None
}
fn node_fingerprint(&self, _node: NodeId) -> Option<u64> {
use std::sync::atomic::Ordering::Relaxed;
if self.fp_present.load(Relaxed) {
Some(self.fp_bits.load(Relaxed))
} else {
None
}
}
}
fn one_led_chain() -> (
HashMap<ChainId, BTreeSet<NodeId>>,
HashMap<ChainId, NodeId>,
NodeId,
) {
let this: NodeId = 100;
let mut replicas: HashMap<ChainId, BTreeSet<NodeId>> = HashMap::new();
replicas.insert(1, BTreeSet::from([100]));
let mut leader: HashMap<ChainId, NodeId> = HashMap::new();
leader.insert(1, this);
(replicas, leader, this)
}
#[test]
fn dirty_gate_skips_rescore_when_fingerprint_stable() {
let (replicas, leader, this) = one_led_chain();
let scorer = CountingScorer::new(0.9, Some(7));
let mut ls = LocalScheduler::new();
let t0 = Instant::now();
let interval = Duration::from_secs(30);
ls.sample(&replicas, &leader, this, &scorer, t0, interval);
assert_eq!(scorer.calls(), 1);
let snap = ls.sample(
&replicas,
&leader,
this,
&scorer,
t0 + Duration::from_secs(1),
interval,
);
assert_eq!(snap.get(1, 100), Some(0.9), "clean chain retains its score");
assert_eq!(
scorer.calls(),
1,
"stable fingerprint within backstop → no rescore"
);
}
#[test]
fn dirty_gate_rescore_when_fingerprint_moves() {
let (replicas, leader, this) = one_led_chain();
let scorer = CountingScorer::new(0.9, Some(7));
let mut ls = LocalScheduler::new();
let t0 = Instant::now();
let interval = Duration::from_secs(30);
ls.sample(&replicas, &leader, this, &scorer, t0, interval);
assert_eq!(scorer.calls(), 1);
scorer.set_fp(8);
ls.sample(
&replicas,
&leader,
this,
&scorer,
t0 + Duration::from_secs(1),
interval,
);
assert_eq!(scorer.calls(), 2, "fingerprint change forces a rescore");
}
#[test]
fn coarse_backstop_rescore_even_when_fingerprint_stable() {
let (replicas, leader, this) = one_led_chain();
let scorer = CountingScorer::new(0.9, Some(7));
let mut ls = LocalScheduler::new();
let t0 = Instant::now();
let interval = Duration::from_secs(30);
ls.sample(&replicas, &leader, this, &scorer, t0, interval);
assert_eq!(scorer.calls(), 1);
ls.sample(&replicas, &leader, this, &scorer, t0 + interval, interval);
assert_eq!(
scorer.calls(),
2,
"backstop forces a rescore past the interval"
);
}
#[test]
fn dirty_gate_rescore_when_holder_set_changes() {
let (mut replicas, leader, this) = one_led_chain();
let scorer = CountingScorer::new(0.9, Some(7)); let mut ls = LocalScheduler::new();
let t0 = Instant::now();
let interval = Duration::from_secs(30);
ls.sample(&replicas, &leader, this, &scorer, t0, interval);
assert_eq!(scorer.calls(), 1, "1 holder scored");
replicas.insert(1, BTreeSet::from([100, 200]));
ls.sample(
&replicas,
&leader,
this,
&scorer,
t0 + Duration::from_secs(1),
interval,
);
assert_eq!(scorer.calls(), 3, "holder-set change rescored both holders");
}
#[test]
fn no_fingerprint_means_always_dirty() {
let (replicas, leader, this) = one_led_chain();
let scorer = CountingScorer::new(0.9, None);
let mut ls = LocalScheduler::new();
let t0 = Instant::now();
let interval = Duration::from_secs(30);
ls.sample(&replicas, &leader, this, &scorer, t0, interval);
ls.sample(
&replicas,
&leader,
this,
&scorer,
t0 + Duration::from_secs(1),
interval,
);
assert_eq!(scorer.calls(), 2, "no fingerprint → re-scored every tick");
}
#[test]
fn migration_cost_model_is_monotonic() {
let model = MigrationCostModel::default();
let base = MigrationCost {
state_transfer: Duration::from_secs(1),
disruption: Duration::from_secs(1),
reliability_factor: 1.0,
};
let base_score = model.score_equivalent(&base);
assert!(base_score > 0.0);
let more_transfer = MigrationCost {
state_transfer: Duration::from_secs(2),
..base.clone()
};
assert!(model.score_equivalent(&more_transfer) > base_score);
let more_disruption = MigrationCost {
disruption: Duration::from_secs(2),
..base.clone()
};
assert!(model.score_equivalent(&more_disruption) > base_score);
let more_important = MigrationCost {
reliability_factor: 2.0,
..base.clone()
};
assert!(model.score_equivalent(&more_important) > base_score);
let zero = MigrationCost::default();
assert_eq!(model.score_equivalent(&zero), 0.0);
}
#[test]
fn migration_cost_default_weight_does_not_zero_the_gate() {
let model = MigrationCostModel::default();
let defaulted = MigrationCost {
state_transfer: Duration::from_secs(10),
disruption: Duration::from_secs(10),
..Default::default()
};
assert!(
model.score_equivalent(&defaulted) > 0.0,
"default-weight cost must be non-zero or the net-benefit gate is a no-op",
);
let neutral = model.score_equivalent(&defaulted);
for bad in [0.0f32, -1.0, f32::NAN] {
let weighted = MigrationCost {
reliability_factor: bad,
..defaulted.clone()
};
assert_eq!(
model.score_equivalent(&weighted),
neutral,
"non-positive / NaN weight ({bad}) must clamp to the neutral 1.0",
);
}
}
}