use std::collections::HashSet;
use crate::adapter::net::behavior::fold::{
CapabilityFold, Fold, IslandId, IslandTopologyFold, JobId, NodeId,
};
use crate::adapter::net::stream::StreamError;
use super::claim::{ClaimError, Claimant};
use super::contention::claim_first_available;
use super::multi::{acquire_gang, GangClaim, GangOutcome};
use super::{match_islands, MatchCriteria};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scheduled {
Single(IslandId),
Gang(Vec<IslandId>),
}
#[derive(Debug)]
pub enum ScheduleError {
Backpressure(StreamError),
Claim(ClaimError),
}
impl ScheduleError {
fn backpressure() -> Self {
ScheduleError::Backpressure(StreamError::Backpressure)
}
}
impl std::fmt::Display for ScheduleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScheduleError::Backpressure(e) => write!(f, "gang scheduler saturated: {e}"),
ScheduleError::Claim(e) => write!(f, "gang claim failed: {e}"),
}
}
}
impl std::error::Error for ScheduleError {}
impl From<ClaimError> for ScheduleError {
fn from(e: ClaimError) -> Self {
ScheduleError::Claim(e)
}
}
pub struct GangScheduler<'a> {
pub(super) capability: &'a Fold<CapabilityFold>,
pub(super) topology: &'a Fold<IslandTopologyFold>,
pub(super) claimant: Claimant<'a>,
}
impl<'a> GangScheduler<'a> {
pub fn new(
capability: &'a Fold<CapabilityFold>,
topology: &'a Fold<IslandTopologyFold>,
claimant: Claimant<'a>,
) -> Self {
Self {
capability,
topology,
claimant,
}
}
}
pub struct GangRequest<'a> {
pub criteria: &'a MatchCriteria,
pub job: JobId,
pub gang_size: usize,
pub reserve_ttl_us: u64,
pub deadline_us: u64,
}
pub fn schedule_single(
scheduler: &mut GangScheduler,
criteria: &MatchCriteria,
reserve_ttl_us: u64,
deadline_us: u64,
now_us: impl Fn() -> u64,
mut backoff: impl FnMut(u32),
) -> Result<Scheduled, ScheduleError> {
let mut attempt = 0u32;
let no_down: HashSet<NodeId> = HashSet::new();
loop {
let islands = match_islands(scheduler.capability, scheduler.topology, criteria, &no_down);
if !islands.is_empty() {
let until = now_us().saturating_add(reserve_ttl_us);
let res = scheduler.claimant.reservations;
let kp = scheduler.claimant.keypair;
let node = scheduler.claimant.node_id;
if let Some(won) = claim_first_available(
res,
kp,
node,
&mut scheduler.claimant.generation,
&islands,
until,
)? {
return Ok(Scheduled::Single(won));
}
}
if now_us() >= deadline_us {
return Err(ScheduleError::backpressure());
}
backoff(attempt);
attempt = attempt.saturating_add(1);
}
}
pub fn schedule_gang(
scheduler: &mut GangScheduler,
req: &GangRequest,
now_us: impl Fn() -> u64 + Copy,
backoff: impl FnMut(u32),
) -> Result<Scheduled, ScheduleError> {
let no_down: HashSet<NodeId> = HashSet::new();
let mut candidates = match_islands(
scheduler.capability,
scheduler.topology,
req.criteria,
&no_down,
);
if candidates.len() < req.gang_size {
return Err(ScheduleError::backpressure());
}
candidates.truncate(req.gang_size);
let claim = GangClaim {
job: req.job,
islands: candidates,
deadline_us: req.deadline_us,
};
match acquire_gang(
&mut scheduler.claimant,
&claim,
req.reserve_ttl_us,
now_us,
backoff,
)? {
GangOutcome::Held(islands) => Ok(Scheduled::Gang(islands)),
GangOutcome::DeadlineExceeded => Err(ScheduleError::backpressure()),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::*;
use crate::adapter::net::behavior::fold::{
CapabilityFilter, CapabilityMembership, CapabilityQuery, EnvelopeMeta, FoldKind,
IslandRecord, NodeState, ReservationFold, ReservationQuery, SignedAnnouncement, UnitSet,
};
use crate::adapter::net::behavior::gang::{
single_island_claim, NumericFilter, SelectionPolicy,
};
use crate::adapter::net::current_timestamp_micros;
use crate::adapter::net::identity::EntityKeypair;
fn new_fold<K: FoldKind>() -> Fold<K> {
Fold::with_sweep_interval(Duration::ZERO)
}
fn announce_capability(fold: &Fold<CapabilityFold>, kp: &EntityKeypair, node: u64) {
let membership = CapabilityMembership {
class_hash: 0x67_70_75,
tags: vec!["gpu:h100".into()],
hardware: None,
state: NodeState::Idle,
region: None,
price_quote: None,
reflex_addr: None,
allowed_nodes: Vec::new(),
allowed_subnets: Vec::new(),
allowed_groups: Vec::new(),
metadata: BTreeMap::new(),
owner: None,
};
fold.apply(
SignedAnnouncement::sign(
kp,
CapabilityFold::KIND_ID,
membership.class_hash,
node,
1,
EnvelopeMeta::default(),
membership,
)
.unwrap(),
)
.unwrap();
}
fn announce_island(
fold: &Fold<IslandTopologyFold>,
kp: &EntityKeypair,
node: u64,
id: IslandId,
) {
let record = IslandRecord {
id,
units: UnitSet::new(vec![0, 1, 2, 3, 4, 5, 6, 7]),
host: node,
capabilities: vec!["model:a1".into()],
load: 0.2,
p50_latency_us: 1_000,
};
fold.apply(
SignedAnnouncement::sign(
kp,
IslandTopologyFold::KIND_ID,
0,
node,
1,
EnvelopeMeta::default(),
record,
)
.unwrap(),
)
.unwrap();
}
fn criteria() -> MatchCriteria {
MatchCriteria {
capability: CapabilityQuery::Composite(CapabilityFilter {
tags_all: vec!["gpu:h100".into()],
..Default::default()
}),
numeric: NumericFilter {
min_units: 8,
..Default::default()
},
selection: SelectionPolicy::LeastLoaded,
prefer_capability: None,
}
}
fn fresh() -> u64 {
current_timestamp_micros() + 60_000_000
}
#[test]
fn schedule_single_claims_when_capacity_exists() {
let caps = new_fold::<CapabilityFold>();
let topo = new_fold::<IslandTopologyFold>();
let res = new_fold::<ReservationFold>();
let kp = EntityKeypair::generate();
let node = kp.entity_id().node_id();
announce_capability(&caps, &kp, node);
announce_island(&topo, &kp, node, 0xA0);
let mut scheduler = GangScheduler::new(&caps, &topo, Claimant::new(&res, &kp, node));
let got = schedule_single(
&mut scheduler,
&criteria(),
60_000_000,
fresh(),
current_timestamp_micros,
|_| {},
)
.unwrap();
assert_eq!(got, Scheduled::Single(0xA0));
}
#[test]
fn schedule_single_surfaces_backpressure_when_no_capacity() {
let caps = new_fold::<CapabilityFold>();
let topo = new_fold::<IslandTopologyFold>();
let res = new_fold::<ReservationFold>();
let kp = EntityKeypair::generate();
let node = kp.entity_id().node_id();
announce_capability(&caps, &kp, node);
let clock = AtomicU64::new(1);
let mut scheduler = GangScheduler::new(&caps, &topo, Claimant::new(&res, &kp, node));
let err = schedule_single(
&mut scheduler,
&criteria(),
60_000_000,
3,
|| clock.fetch_add(1, Ordering::Relaxed),
|_| {},
)
.unwrap_err();
assert!(
matches!(err, ScheduleError::Backpressure(StreamError::Backpressure)),
"no capacity must surface as StreamError::Backpressure, got {err:?}",
);
}
#[test]
fn schedule_single_retries_and_wins_after_a_contended_island_frees() {
let caps = new_fold::<CapabilityFold>();
let topo = new_fold::<IslandTopologyFold>();
let res = new_fold::<ReservationFold>();
let kp = EntityKeypair::generate();
let node = kp.entity_id().node_id();
announce_capability(&caps, &kp, node);
announce_island(&topo, &kp, node, 0xA0);
let other = EntityKeypair::generate();
let on = other.entity_id().node_id();
single_island_claim(&res, &other, on, 1, 0xA0, fresh()).unwrap();
let mut released = false;
let backoff = |_a: u32| {
if !released {
crate::adapter::net::behavior::gang::release_island(&res, &other, on, 2, 0xA0)
.unwrap();
released = true;
}
};
let mut scheduler = GangScheduler::new(&caps, &topo, Claimant::new(&res, &kp, node));
let got = schedule_single(
&mut scheduler,
&criteria(),
60_000_000,
u64::MAX,
current_timestamp_micros,
backoff,
)
.unwrap();
assert_eq!(got, Scheduled::Single(0xA0));
assert_eq!(
res.query(ReservationQuery::State(0xA0))[0].1.holder(),
Some(node),
);
}
#[test]
fn schedule_gang_acquires_top_k_islands() {
let caps = new_fold::<CapabilityFold>();
let topo = new_fold::<IslandTopologyFold>();
let res = new_fold::<ReservationFold>();
let kp = EntityKeypair::generate();
let node = kp.entity_id().node_id();
announce_capability(&caps, &kp, node);
for id in [0xA0, 0xA1, 0xA2] {
announce_island(&topo, &kp, node, id);
}
let crit = criteria();
let mut scheduler = GangScheduler::new(&caps, &topo, Claimant::new(&res, &kp, node));
let got = schedule_gang(
&mut scheduler,
&GangRequest {
criteria: &crit,
job: 42,
gang_size: 2,
reserve_ttl_us: 60_000_000,
deadline_us: fresh(),
},
current_timestamp_micros,
|_| {},
)
.unwrap();
match got {
Scheduled::Gang(islands) => assert_eq!(islands.len(), 2),
other => panic!("expected a 2-island gang, got {other:?}"),
}
}
#[test]
fn schedule_gang_backpressures_when_too_few_islands_match() {
let caps = new_fold::<CapabilityFold>();
let topo = new_fold::<IslandTopologyFold>();
let res = new_fold::<ReservationFold>();
let kp = EntityKeypair::generate();
let node = kp.entity_id().node_id();
announce_capability(&caps, &kp, node);
announce_island(&topo, &kp, node, 0xA0);
let crit = criteria();
let mut scheduler = GangScheduler::new(&caps, &topo, Claimant::new(&res, &kp, node));
let err = schedule_gang(
&mut scheduler,
&GangRequest {
criteria: &crit,
job: 42,
gang_size: 3,
reserve_ttl_us: 60_000_000,
deadline_us: fresh(),
},
current_timestamp_micros,
|_| {},
)
.unwrap_err();
assert!(matches!(
err,
ScheduleError::Backpressure(StreamError::Backpressure)
));
}
}