Skip to main content

tatara_engine/domain/
evaluation.rs

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/// Scheduling strategy for task placement.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum SchedulingStrategy {
12    /// Bin-pack: prefer nodes with the least remaining capacity (tightest fit).
13    BinPack,
14    /// Spread: prefer nodes with the most remaining capacity (even distribution).
15    Spread,
16}
17
18impl Default for SchedulingStrategy {
19    fn default() -> Self {
20        Self::BinPack
21    }
22}
23
24/// Evaluates pending jobs and creates allocation plans.
25pub 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    /// Process all pending jobs and create allocations.
44    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        // Filter to ready, eligible nodes
74        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            // Track available resources as we allocate (for this eval cycle)
92            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                                // Deduct resources from tracking
109                                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
149/// Pick the best node for a task group based on resource requirements,
150/// constraints, and scheduling strategy.
151///
152/// Returns the node and its index in the candidates list, or None if
153/// no node satisfies the requirements.
154fn 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        // Resource filtering: node must have enough resources
164        if !resources_sufficient(available, required) {
165            continue;
166        }
167
168        // Constraint evaluation: all constraints must match
169        if !constraints_match(node, constraints) {
170            continue;
171        }
172
173        // Score the node based on strategy
174        let score = match strategy {
175            SchedulingStrategy::BinPack => {
176                // Lower remaining = better score (tighter packing)
177                // We want to MINIMIZE remaining capacity, so higher score = less remaining
178                let remaining_cpu = available.cpu_mhz.saturating_sub(required.cpu_mhz);
179                let remaining_mem = available.memory_mb.saturating_sub(required.memory_mb);
180                // Invert: smaller remaining gets higher score
181                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                // Higher remaining = better score (more spread out)
189                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
209/// Check if available resources meet the requirements.
210fn resources_sufficient(available: &Resources, required: &Resources) -> bool {
211    // If no resources are requested (both 0), any node qualifies
212    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
220/// Evaluate all constraints against a node's attributes.
221fn constraints_match(node: &Node, constraints: &[Constraint]) -> bool {
222    constraints.iter().all(|c| constraint_matches(node, c))
223}
224
225/// Evaluate a single constraint against a node.
226fn constraint_matches(node: &Node, constraint: &Constraint) -> bool {
227    let attr_value = match constraint.attribute.as_str() {
228        // Built-in attributes
229        "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        // Generic attribute lookup
243        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        // Attribute not found on node — constraint fails unless operator is "!="
254        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
296/// Simple regex match using basic patterns (no full regex crate dependency).
297fn regex_match(value: &str, pattern: &str) -> bool {
298    // Simple glob-like matching: * matches anything
299    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        // Bin-pack should prefer the smaller node (tighter fit)
430        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        // Spread should prefer the bigger node (more remaining capacity)
450        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        // The evaluator filters ineligible nodes before calling pick_node,
502        // but verify that the field exists and is respected.
503        assert!(!node.eligible);
504    }
505}