use std::collections::BTreeSet;
use clawft_types::placement::engine::Preference;
use clawft_types::placement::{
AttrPredicate, AttrValue, Capability, CapabilityId, CapabilityState, Requirement,
};
use super::body::{MODEL_MARKER_PREFIX, ModelPackageBody};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransferPolicy {
pub allow_weight_transfer: bool,
pub max_bytes: Option<u64>,
pub free_headroom_bytes: u64,
}
impl Default for TransferPolicy {
fn default() -> Self {
Self {
allow_weight_transfer: false,
max_bytes: None,
free_headroom_bytes: 1024 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sharing {
Allowed,
Refused,
}
impl Sharing {
pub fn from_manifest(body: &ModelPackageBody) -> Self {
if body.redistributable { Self::Allowed } else { Self::Refused }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeHolding {
pub node_id: String,
pub eligible: bool,
pub shards: BTreeSet<String>,
pub complete: bool,
pub free_bytes: Option<u64>,
}
impl NodeHolding {
pub fn from_capabilities(
node_id: &str,
package_id: &str,
body: &ModelPackageBody,
caps: &[Capability],
eligible: bool,
) -> Self {
let marker = format!("{MODEL_MARKER_PREFIX}{package_id}");
let wanted: BTreeSet<&str> = body.shard_hashes().collect();
let mut shards = BTreeSet::new();
let mut complete = false;
for c in caps.iter().filter(|c| c.id.as_str() == "model.present") {
let list = match c.attrs.get("shards") {
Some(AttrValue::List(l)) => l,
_ => continue,
};
let items: Vec<&str> = list
.iter()
.filter_map(|v| match v {
AttrValue::Str(s) => Some(s.as_str()),
_ => None,
})
.collect();
let has_marker = items.contains(&marker.as_str());
match (c.state, has_marker) {
(CapabilityState::Available | CapabilityState::Busy, true) => {
complete = true;
shards.extend(wanted.iter().map(|s| s.to_string()));
}
(_, true) => {}
(_, false) => {
shards.extend(items.into_iter().filter(|s| wanted.contains(s)).map(String::from));
}
}
}
let free_bytes = caps
.iter()
.filter(|c| c.id.as_str().starts_with("store.tier."))
.filter(|c| c.state == CapabilityState::Available)
.filter_map(|c| c.attrs.get("free").and_then(|v| v.as_f64()))
.filter(|f| *f >= 0.0)
.map(|f| f as u64)
.max();
Self {
node_id: node_id.to_string(),
eligible,
shards,
complete,
free_bytes,
}
}
fn missing_bytes(&self, body: &ModelPackageBody) -> u64 {
body.shards
.iter()
.filter(|s| !self.shards.contains(&s.blake3))
.map(|s| s.size)
.fold(0, u64::saturating_add)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unplaceable {
NoHolder,
NoEligibleNode,
TransferDisabled,
NotRedistributable,
OverCeiling {
bytes: u64,
max: u64,
},
NoSpace {
needed: u64,
free: Option<u64>,
},
}
impl std::fmt::Display for Unplaceable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoHolder => write!(f, "no node holds these weights; adopt them on a node first"),
Self::NoEligibleNode => write!(f, "no node passes the other placement constraints"),
Self::TransferDisabled => {
write!(f, "weights are held elsewhere and allow_weight_transfer is off")
}
Self::NotRedistributable => {
write!(f, "weights are held elsewhere and the manifest is not marked redistributable")
}
Self::OverCeiling { bytes, max } => {
write!(f, "transfer of {bytes} bytes exceeds the {max} byte ceiling")
}
Self::NoSpace { needed, free: Some(free) } => {
write!(f, "target needs {needed} bytes free, has {free}")
}
Self::NoSpace { needed, free: None } => {
write!(f, "target needs {needed} bytes free and its free space is unknown")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalityDecision {
PlaceAtHolder {
node_id: String,
},
FetchThenPlace {
target: String,
sources: Vec<String>,
bytes: u64,
missing_shards: usize,
},
Unplaceable(Unplaceable),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalityPlan {
pub decision: LocalityDecision,
pub notes: Vec<String>,
}
pub fn decide(
body: &ModelPackageBody,
nodes: &[NodeHolding],
policy: &TransferPolicy,
sharing: Sharing,
) -> LocalityPlan {
let mut notes = vec![format!(
"model {} ({} shards, {} bytes)",
body.name,
body.shards.len(),
body.total_bytes()
)];
let mut sorted: Vec<&NodeHolding> = nodes.iter().collect();
sorted.sort_by(|a, b| a.node_id.cmp(&b.node_id));
for n in &sorted {
notes.push(format!(
"node {}: eligible={} complete={} holds {}/{} shards",
n.node_id,
n.eligible,
n.complete,
n.shards.len(),
body.shards.len()
));
}
let done = |decision, mut notes: Vec<String>, line: String| {
notes.push(line);
LocalityPlan { decision, notes }
};
if let Some(n) = sorted.iter().find(|n| n.eligible && n.complete) {
return done(
LocalityDecision::PlaceAtHolder { node_id: n.node_id.clone() },
notes,
format!("relocate: {} already holds every shard, nothing is copied", n.node_id),
);
}
let sources: Vec<String> = sorted
.iter()
.filter(|n| n.complete)
.map(|n| n.node_id.clone())
.collect();
let fail = |u: Unplaceable, notes: Vec<String>| {
let line = format!("unplaceable: {u}");
done(LocalityDecision::Unplaceable(u), notes, line)
};
let eligible: Vec<&&NodeHolding> = sorted.iter().filter(|n| n.eligible).collect();
if eligible.is_empty() {
return fail(Unplaceable::NoEligibleNode, notes);
}
if sources.is_empty() {
return fail(Unplaceable::NoHolder, notes);
}
if !policy.allow_weight_transfer {
return fail(Unplaceable::TransferDisabled, notes);
}
if sharing != Sharing::Allowed {
return fail(Unplaceable::NotRedistributable, notes);
}
let mut targets: Vec<(&NodeHolding, u64)> =
eligible.iter().map(|n| (**n, n.missing_bytes(body))).collect();
targets.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.node_id.cmp(&b.0.node_id)));
let mut first_failure = None;
for (n, bytes) in targets {
let verdict = (|| {
if let Some(max) = policy.max_bytes
&& bytes > max
{
return Err(Unplaceable::OverCeiling { bytes, max });
}
let needed = bytes.saturating_add(policy.free_headroom_bytes);
match n.free_bytes {
Some(free) if free >= needed => Ok(()),
free => Err(Unplaceable::NoSpace { needed, free }),
}
})();
match verdict {
Ok(()) => {
let missing_shards = body
.shards
.iter()
.filter(|s| !n.shards.contains(&s.blake3))
.count();
let line = format!(
"fetch: {} gets {missing_shards} shard(s), {bytes} bytes, from {}",
n.node_id,
sources.join(",")
);
return done(
LocalityDecision::FetchThenPlace {
target: n.node_id.clone(),
sources,
bytes,
missing_shards,
},
notes,
line,
);
}
Err(u) => {
notes.push(format!("target {} rejected: {u}", n.node_id));
first_failure.get_or_insert(u);
}
}
}
fail(first_failure.unwrap_or(Unplaceable::NoEligibleNode), notes)
}
pub fn model_present_requirement(package_id: &str) -> Requirement {
Requirement::exact(CapabilityId::new("model.present").expect("valid id"))
.with_where(AttrPredicate::has(
"shards",
AttrValue::Str(format!("{MODEL_MARKER_PREFIX}{package_id}")),
))
}
pub fn locality_preference(body: &ModelPackageBody, package_id: &str, weight: f64) -> Preference {
Preference {
name: format!("model-local:{}", body.name),
requirement: model_present_requirement(package_id),
weight,
}
}