1use anyhow::Result;
2use std::sync::Arc;
3
4use crate::domain::store_adapter::ClusterStoreAdapter;
5use tatara_core::domain::allocation::Allocation;
6use tatara_core::domain::job::{Constraint, Job, JobStatus, JobType, Resources};
7use tatara_core::domain::node::{Node, NodeStatus};
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum SchedulingStrategy {
12 BinPack,
14 Spread,
16}
17
18impl Default for SchedulingStrategy {
19 fn default() -> Self {
20 Self::BinPack
21 }
22}
23
24pub struct Evaluator {
26 store: Arc<ClusterStoreAdapter>,
27 strategy: SchedulingStrategy,
28}
29
30impl Evaluator {
31 pub fn new(store: Arc<ClusterStoreAdapter>) -> Self {
32 Self {
33 store,
34 strategy: SchedulingStrategy::default(),
35 }
36 }
37
38 pub fn with_strategy(mut self, strategy: SchedulingStrategy) -> Self {
39 self.strategy = strategy;
40 self
41 }
42
43 pub async fn evaluate(&self) -> Result<Vec<Allocation>> {
45 let jobs = self.store.list_jobs().await;
46 let nodes = self.store.list_nodes().await;
47 let mut new_allocations = Vec::new();
48
49 for job in &jobs {
50 if job.status != JobStatus::Pending {
51 continue;
52 }
53
54 let allocs = self.evaluate_job(job, &nodes).await?;
55 for alloc in allocs {
56 self.store.put_allocation(alloc.clone()).await?;
57 new_allocations.push(alloc);
58 }
59
60 if !new_allocations.is_empty() {
61 self.store
62 .update_job_status(&job.id, JobStatus::Running)
63 .await?;
64 }
65 }
66
67 Ok(new_allocations)
68 }
69
70 async fn evaluate_job(&self, job: &Job, nodes: &[Node]) -> Result<Vec<Allocation>> {
71 let mut allocations = Vec::new();
72
73 let ready_nodes: Vec<&Node> = nodes
75 .iter()
76 .filter(|n| n.status == NodeStatus::Ready)
77 .filter(|n| n.eligible)
78 .collect();
79
80 if ready_nodes.is_empty() {
81 tracing::warn!(job_id = %job.id, "No ready/eligible nodes available for scheduling");
82 return Ok(allocations);
83 }
84
85 for group in &job.groups {
86 let count = match job.job_type {
87 JobType::System => ready_nodes.len() as u32,
88 _ => group.count,
89 };
90
91 let mut node_available: Vec<(&Node, Resources)> = ready_nodes
93 .iter()
94 .map(|n| (*n, n.available_resources.clone()))
95 .collect();
96
97 for i in 0..count {
98 let node = match job.job_type {
99 JobType::System => ready_nodes[i as usize],
100 _ => {
101 match pick_node(
102 &node_available,
103 &group.resources,
104 &job.constraints,
105 self.strategy,
106 ) {
107 Some((node, idx)) => {
108 node_available[idx].1.cpu_mhz = node_available[idx]
110 .1
111 .cpu_mhz
112 .saturating_sub(group.resources.cpu_mhz);
113 node_available[idx].1.memory_mb = node_available[idx]
114 .1
115 .memory_mb
116 .saturating_sub(group.resources.memory_mb);
117 node
118 }
119 None => {
120 tracing::warn!(
121 job_id = %job.id,
122 group = %group.name,
123 instance = i,
124 "No node with sufficient resources for allocation"
125 );
126 continue;
127 }
128 }
129 }
130 };
131
132 let task_names: Vec<String> = group.tasks.iter().map(|t| t.name.clone()).collect();
133
134 let alloc = Allocation::new(
135 job.id.clone(),
136 group.name.clone(),
137 node.id.clone(),
138 task_names,
139 );
140
141 allocations.push(alloc);
142 }
143 }
144
145 Ok(allocations)
146 }
147}
148
149fn pick_node<'a>(
155 candidates: &[(&'a Node, Resources)],
156 required: &Resources,
157 constraints: &[Constraint],
158 strategy: SchedulingStrategy,
159) -> Option<(&'a Node, usize)> {
160 let mut best: Option<(usize, f64)> = None;
161
162 for (idx, (node, available)) in candidates.iter().enumerate() {
163 if !resources_sufficient(available, required) {
165 continue;
166 }
167
168 if !constraints_match(node, constraints) {
170 continue;
171 }
172
173 let score = match strategy {
175 SchedulingStrategy::BinPack => {
176 let remaining_cpu = available.cpu_mhz.saturating_sub(required.cpu_mhz);
179 let remaining_mem = available.memory_mb.saturating_sub(required.memory_mb);
180 let max_cpu = node.total_resources.cpu_mhz.max(1) as f64;
182 let max_mem = node.total_resources.memory_mb.max(1) as f64;
183 let cpu_utilization = 1.0 - (remaining_cpu as f64 / max_cpu);
184 let mem_utilization = 1.0 - (remaining_mem as f64 / max_mem);
185 (cpu_utilization + mem_utilization) / 2.0
186 }
187 SchedulingStrategy::Spread => {
188 let remaining_cpu = available.cpu_mhz.saturating_sub(required.cpu_mhz);
190 let remaining_mem = available.memory_mb.saturating_sub(required.memory_mb);
191 let max_cpu = node.total_resources.cpu_mhz.max(1) as f64;
192 let max_mem = node.total_resources.memory_mb.max(1) as f64;
193 let cpu_headroom = remaining_cpu as f64 / max_cpu;
194 let mem_headroom = remaining_mem as f64 / max_mem;
195 (cpu_headroom + mem_headroom) / 2.0
196 }
197 };
198
199 match best {
200 None => best = Some((idx, score)),
201 Some((_, best_score)) if score > best_score => best = Some((idx, score)),
202 _ => {}
203 }
204 }
205
206 best.map(|(idx, _)| (candidates[idx].0, idx))
207}
208
209fn resources_sufficient(available: &Resources, required: &Resources) -> bool {
211 if required.cpu_mhz == 0 && required.memory_mb == 0 {
213 return true;
214 }
215
216 (required.cpu_mhz == 0 || available.cpu_mhz >= required.cpu_mhz)
217 && (required.memory_mb == 0 || available.memory_mb >= required.memory_mb)
218}
219
220fn constraints_match(node: &Node, constraints: &[Constraint]) -> bool {
222 constraints.iter().all(|c| constraint_matches(node, c))
223}
224
225fn constraint_matches(node: &Node, constraint: &Constraint) -> bool {
227 let attr_value = match constraint.attribute.as_str() {
228 "os" | "${attr.os}" => Some(node.attributes.get("os").map(|s| s.as_str()).unwrap_or("")),
230 "arch" | "${attr.arch}" => Some(
231 node.attributes
232 .get("arch")
233 .map(|s| s.as_str())
234 .unwrap_or(""),
235 ),
236 "hostname" | "${attr.hostname}" => Some(
237 node.attributes
238 .get("hostname")
239 .map(|s| s.as_str())
240 .unwrap_or(""),
241 ),
242 attr => {
244 let key = attr
245 .strip_prefix("${attr.")
246 .and_then(|s| s.strip_suffix('}'))
247 .unwrap_or(attr);
248 node.attributes.get(key).map(|s| s.as_str())
249 }
250 };
251
252 let Some(attr_value) = attr_value else {
253 return constraint.operator == "!=";
255 };
256
257 match constraint.operator.as_str() {
258 "=" | "==" => attr_value == constraint.value,
259 "!=" => attr_value != constraint.value,
260 ">" => attr_value
261 .parse::<f64>()
262 .ok()
263 .zip(constraint.value.parse::<f64>().ok())
264 .map(|(a, b)| a > b)
265 .unwrap_or(false),
266 "<" => attr_value
267 .parse::<f64>()
268 .ok()
269 .zip(constraint.value.parse::<f64>().ok())
270 .map(|(a, b)| a < b)
271 .unwrap_or(false),
272 ">=" => attr_value
273 .parse::<f64>()
274 .ok()
275 .zip(constraint.value.parse::<f64>().ok())
276 .map(|(a, b)| a >= b)
277 .unwrap_or(false),
278 "<=" => attr_value
279 .parse::<f64>()
280 .ok()
281 .zip(constraint.value.parse::<f64>().ok())
282 .map(|(a, b)| a <= b)
283 .unwrap_or(false),
284 "regexp" | "~" => regex_match(attr_value, &constraint.value),
285 "set_contains" => attr_value.split(',').any(|v| v.trim() == constraint.value),
286 _ => {
287 tracing::warn!(
288 operator = %constraint.operator,
289 "Unknown constraint operator, defaulting to equality"
290 );
291 attr_value == constraint.value
292 }
293 }
294}
295
296fn regex_match(value: &str, pattern: &str) -> bool {
298 if pattern == "*" {
300 return true;
301 }
302 if let Some(suffix) = pattern.strip_prefix('*') {
303 return value.ends_with(suffix);
304 }
305 if let Some(prefix) = pattern.strip_suffix('*') {
306 return value.starts_with(prefix);
307 }
308 value == pattern
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use std::collections::HashMap;
315
316 fn make_node(id: &str, cpu: u64, mem: u64, attrs: &[(&str, &str)]) -> Node {
317 let mut attributes = HashMap::new();
318 for (k, v) in attrs {
319 attributes.insert(k.to_string(), v.to_string());
320 }
321 Node {
322 id: id.to_string(),
323 address: "127.0.0.1:4647".to_string(),
324 status: NodeStatus::Ready,
325 eligible: true,
326 total_resources: Resources {
327 cpu_mhz: cpu,
328 memory_mb: mem,
329 },
330 available_resources: Resources {
331 cpu_mhz: cpu,
332 memory_mb: mem,
333 },
334 attributes,
335 drivers: vec![],
336 last_heartbeat: chrono::Utc::now(),
337 allocations: Vec::new(),
338 }
339 }
340
341 #[test]
342 fn test_resources_sufficient() {
343 let available = Resources {
344 cpu_mhz: 2000,
345 memory_mb: 1024,
346 };
347 let required = Resources {
348 cpu_mhz: 1000,
349 memory_mb: 512,
350 };
351 assert!(resources_sufficient(&available, &required));
352
353 let too_much = Resources {
354 cpu_mhz: 3000,
355 memory_mb: 512,
356 };
357 assert!(!resources_sufficient(&available, &too_much));
358 }
359
360 #[test]
361 fn test_resources_zero_means_any() {
362 let available = Resources {
363 cpu_mhz: 100,
364 memory_mb: 64,
365 };
366 let zero = Resources {
367 cpu_mhz: 0,
368 memory_mb: 0,
369 };
370 assert!(resources_sufficient(&available, &zero));
371 }
372
373 #[test]
374 fn test_constraint_equality() {
375 let node = make_node("n1", 2000, 1024, &[("os", "linux"), ("arch", "x86_64")]);
376 let c = Constraint {
377 attribute: "os".to_string(),
378 operator: "=".to_string(),
379 value: "linux".to_string(),
380 };
381 assert!(constraint_matches(&node, &c));
382
383 let c2 = Constraint {
384 attribute: "os".to_string(),
385 operator: "=".to_string(),
386 value: "macos".to_string(),
387 };
388 assert!(!constraint_matches(&node, &c2));
389 }
390
391 #[test]
392 fn test_constraint_not_equal() {
393 let node = make_node("n1", 2000, 1024, &[("os", "linux")]);
394 let c = Constraint {
395 attribute: "os".to_string(),
396 operator: "!=".to_string(),
397 value: "windows".to_string(),
398 };
399 assert!(constraint_matches(&node, &c));
400 }
401
402 #[test]
403 fn test_constraint_missing_attribute() {
404 let node = make_node("n1", 2000, 1024, &[("os", "linux")]);
405 let c = Constraint {
406 attribute: "gpu".to_string(),
407 operator: "=".to_string(),
408 value: "true".to_string(),
409 };
410 assert!(!constraint_matches(&node, &c));
411 }
412
413 #[test]
414 fn test_bin_pack_picks_tightest_fit() {
415 let n1 = make_node("big", 4000, 2048, &[]);
416 let n2 = make_node("small", 2000, 1024, &[]);
417
418 let candidates = vec![
419 (&n1, n1.available_resources.clone()),
420 (&n2, n2.available_resources.clone()),
421 ];
422 let required = Resources {
423 cpu_mhz: 1000,
424 memory_mb: 512,
425 };
426
427 let result = pick_node(&candidates, &required, &[], SchedulingStrategy::BinPack);
428 assert!(result.is_some());
429 assert_eq!(result.unwrap().0.id, "small");
431 }
432
433 #[test]
434 fn test_spread_picks_most_headroom() {
435 let n1 = make_node("big", 4000, 2048, &[]);
436 let n2 = make_node("small", 2000, 1024, &[]);
437
438 let candidates = vec![
439 (&n1, n1.available_resources.clone()),
440 (&n2, n2.available_resources.clone()),
441 ];
442 let required = Resources {
443 cpu_mhz: 1000,
444 memory_mb: 512,
445 };
446
447 let result = pick_node(&candidates, &required, &[], SchedulingStrategy::Spread);
448 assert!(result.is_some());
449 assert_eq!(result.unwrap().0.id, "big");
451 }
452
453 #[test]
454 fn test_no_node_with_sufficient_resources() {
455 let n1 = make_node("tiny", 500, 256, &[]);
456
457 let candidates = vec![(&n1, n1.available_resources.clone())];
458 let required = Resources {
459 cpu_mhz: 1000,
460 memory_mb: 512,
461 };
462
463 let result = pick_node(&candidates, &required, &[], SchedulingStrategy::BinPack);
464 assert!(result.is_none());
465 }
466
467 #[test]
468 fn test_constraints_filter_nodes() {
469 let linux = make_node("linux-box", 4000, 2048, &[("os", "linux")]);
470 let mac = make_node("mac-box", 4000, 2048, &[("os", "macos")]);
471
472 let candidates = vec![
473 (&linux, linux.available_resources.clone()),
474 (&mac, mac.available_resources.clone()),
475 ];
476 let required = Resources {
477 cpu_mhz: 1000,
478 memory_mb: 512,
479 };
480 let constraints = vec![Constraint {
481 attribute: "os".to_string(),
482 operator: "=".to_string(),
483 value: "linux".to_string(),
484 }];
485
486 let result = pick_node(
487 &candidates,
488 &required,
489 &constraints,
490 SchedulingStrategy::BinPack,
491 );
492 assert!(result.is_some());
493 assert_eq!(result.unwrap().0.id, "linux-box");
494 }
495
496 #[test]
497 fn test_ineligible_node_excluded() {
498 let mut node = make_node("n1", 4000, 2048, &[]);
499 node.eligible = false;
500
501 assert!(!node.eligible);
504 }
505}