1#![allow(dead_code)]
7
8use crate::consensus::NodeId;
9
10#[derive(Debug, Clone)]
12pub struct WorkerCapability {
13 pub cpu_cores: u32,
15 pub memory_gb: f32,
17 pub gpu_vram_gb: f32,
19 pub network_mbps: u32,
21 pub tags: Vec<String>,
23}
24
25impl WorkerCapability {
26 #[must_use]
28 pub fn new(
29 cpu_cores: u32,
30 memory_gb: f32,
31 gpu_vram_gb: f32,
32 network_mbps: u32,
33 tags: Vec<String>,
34 ) -> Self {
35 Self {
36 cpu_cores,
37 memory_gb,
38 gpu_vram_gb,
39 network_mbps,
40 tags,
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct TaskRequirements {
48 pub min_cpu_cores: u32,
50 pub min_memory_gb: f32,
52 pub requires_gpu: bool,
54 pub min_gpu_vram_gb: f32,
56 pub preferred_tags: Vec<String>,
58}
59
60impl TaskRequirements {
61 #[must_use]
63 pub fn new(
64 min_cpu_cores: u32,
65 min_memory_gb: f32,
66 requires_gpu: bool,
67 min_gpu_vram_gb: f32,
68 preferred_tags: Vec<String>,
69 ) -> Self {
70 Self {
71 min_cpu_cores,
72 min_memory_gb,
73 requires_gpu,
74 min_gpu_vram_gb,
75 preferred_tags,
76 }
77 }
78}
79
80pub struct AffinityScore;
82
83impl AffinityScore {
84 #[must_use]
92 pub fn compute(capability: &WorkerCapability, requirements: &TaskRequirements) -> f32 {
93 if capability.cpu_cores < requirements.min_cpu_cores {
95 return 0.0;
96 }
97 if capability.memory_gb < requirements.min_memory_gb {
98 return 0.0;
99 }
100 if requirements.requires_gpu && capability.gpu_vram_gb < requirements.min_gpu_vram_gb {
101 return 0.0;
102 }
103
104 let mut score = 0.5_f32;
105
106 if requirements.requires_gpu && capability.gpu_vram_gb >= requirements.min_gpu_vram_gb {
108 score += 0.3;
109 }
110
111 for tag in &requirements.preferred_tags {
113 if capability.tags.contains(tag) {
114 score += 0.1;
115 }
116 }
117
118 score.min(1.0)
119 }
120}
121
122#[derive(Debug, Clone)]
124pub struct WorkerStatus {
125 pub id: NodeId,
127 pub capability: WorkerCapability,
129 pub load_pct: f32,
131 pub task_count: u32,
133}
134
135impl WorkerStatus {
136 #[must_use]
138 pub fn new(id: NodeId, capability: WorkerCapability, load_pct: f32, task_count: u32) -> Self {
139 Self {
140 id,
141 capability,
142 load_pct,
143 task_count,
144 }
145 }
146}
147
148pub struct WorkloadBalancer;
150
151impl WorkloadBalancer {
152 #[must_use]
157 pub fn assign_task(
158 workers: &[WorkerStatus],
159 requirements: &TaskRequirements,
160 ) -> Option<NodeId> {
161 workers
162 .iter()
163 .filter_map(|w| {
164 let affinity = AffinityScore::compute(&w.capability, requirements);
165 if affinity == 0.0 {
166 return None;
167 }
168 let effective = affinity * (1.0 - w.load_pct.clamp(0.0, 1.0));
169 Some((w.id, effective))
170 })
171 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
172 .map(|(id, _)| id)
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum MigrationReason {
179 LoadBalance,
181 NodeFailure,
183 ResourceExhausted,
185 UserRequest,
187}
188
189#[derive(Debug, Clone)]
191pub struct TaskMigration {
192 pub task_id: String,
194 pub from_node: NodeId,
196 pub to_node: NodeId,
198 pub reason: MigrationReason,
200 pub data_size_bytes: u64,
202}
203
204pub struct MigrationPlanner;
206
207impl MigrationPlanner {
208 #[must_use]
213 pub fn plan(workers: &[WorkerStatus], threshold_imbalance: f32) -> Vec<TaskMigration> {
214 let mut migrations = Vec::new();
215
216 let overloaded: Vec<&WorkerStatus> = workers
218 .iter()
219 .filter(|w| w.load_pct > threshold_imbalance)
220 .collect();
221
222 let underloaded: Vec<&WorkerStatus> = workers
223 .iter()
224 .filter(|w| w.load_pct < 1.0 - threshold_imbalance)
225 .collect();
226
227 for (idx, over) in overloaded.iter().enumerate() {
228 if let Some(under) = underloaded.get(idx) {
229 migrations.push(TaskMigration {
230 task_id: format!("task-from-{}", over.id.inner()),
231 from_node: over.id,
232 to_node: under.id,
233 reason: MigrationReason::LoadBalance,
234 data_size_bytes: 0,
235 });
236 }
237 }
238
239 migrations
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn cpu_worker(id: u64, cores: u32, load: f32) -> WorkerStatus {
248 WorkerStatus::new(
249 NodeId::new(id),
250 WorkerCapability::new(cores, 16.0, 0.0, 1000, vec![]),
251 load,
252 (load * 10.0) as u32,
253 )
254 }
255
256 fn gpu_worker(id: u64, vram: f32, load: f32) -> WorkerStatus {
257 WorkerStatus::new(
258 NodeId::new(id),
259 WorkerCapability::new(8, 32.0, vram, 10_000, vec!["gpu".to_string()]),
260 load,
261 (load * 10.0) as u32,
262 )
263 }
264
265 fn basic_requirements() -> TaskRequirements {
266 TaskRequirements::new(4, 8.0, false, 0.0, vec![])
267 }
268
269 fn gpu_requirements() -> TaskRequirements {
270 TaskRequirements::new(4, 8.0, true, 8.0, vec!["gpu".to_string()])
271 }
272
273 #[test]
274 fn test_affinity_meets_minimums() {
275 let cap = WorkerCapability::new(8, 16.0, 0.0, 1000, vec![]);
276 let req = basic_requirements();
277 let score = AffinityScore::compute(&cap, &req);
278 assert!((score - 0.5).abs() < 1e-5, "Expected 0.5, got {score}");
279 }
280
281 #[test]
282 fn test_affinity_fails_cpu() {
283 let cap = WorkerCapability::new(2, 16.0, 0.0, 1000, vec![]);
284 let req = TaskRequirements::new(4, 8.0, false, 0.0, vec![]);
285 assert_eq!(AffinityScore::compute(&cap, &req), 0.0);
286 }
287
288 #[test]
289 fn test_affinity_fails_memory() {
290 let cap = WorkerCapability::new(8, 4.0, 0.0, 1000, vec![]);
291 let req = TaskRequirements::new(4, 8.0, false, 0.0, vec![]);
292 assert_eq!(AffinityScore::compute(&cap, &req), 0.0);
293 }
294
295 #[test]
296 fn test_affinity_gpu_bonus() {
297 let cap = WorkerCapability::new(8, 32.0, 16.0, 10_000, vec!["gpu".to_string()]);
298 let req = gpu_requirements();
299 let score = AffinityScore::compute(&cap, &req);
300 assert!((score - 0.9).abs() < 1e-5, "Expected 0.9, got {score}");
302 }
303
304 #[test]
305 fn test_affinity_tag_bonus() {
306 let cap = WorkerCapability::new(
307 8,
308 16.0,
309 0.0,
310 1000,
311 vec!["av1".to_string(), "fast".to_string()],
312 );
313 let req = TaskRequirements::new(
314 4,
315 8.0,
316 false,
317 0.0,
318 vec!["av1".to_string(), "fast".to_string()],
319 );
320 let score = AffinityScore::compute(&cap, &req);
321 assert!((score - 0.7).abs() < 1e-5, "Expected 0.7, got {score}");
323 }
324
325 #[test]
326 fn test_affinity_capped_at_one() {
327 let tags: Vec<String> = (0..10).map(|i| format!("tag{i}")).collect();
328 let cap = WorkerCapability::new(8, 16.0, 16.0, 10_000, tags.clone());
329 let req = TaskRequirements::new(4, 8.0, true, 8.0, tags);
330 let score = AffinityScore::compute(&cap, &req);
331 assert!(score <= 1.0, "Score must not exceed 1.0");
332 }
333
334 #[test]
335 fn test_workload_balancer_selects_best() {
336 let workers = vec![
337 cpu_worker(1, 8, 0.9), cpu_worker(2, 8, 0.1), cpu_worker(3, 8, 0.5),
340 ];
341 let req = basic_requirements();
342 let assigned = WorkloadBalancer::assign_task(&workers, &req);
343 assert_eq!(assigned, Some(NodeId::new(2)));
344 }
345
346 #[test]
347 fn test_workload_balancer_no_capable_worker() {
348 let workers = vec![WorkerStatus::new(
349 NodeId::new(1),
350 WorkerCapability::new(2, 4.0, 0.0, 1000, vec![]),
351 0.0,
352 0,
353 )];
354 let req = TaskRequirements::new(8, 32.0, false, 0.0, vec![]);
355 let assigned = WorkloadBalancer::assign_task(&workers, &req);
356 assert!(assigned.is_none());
357 }
358
359 #[test]
360 fn test_workload_balancer_gpu_task() {
361 let workers = vec![cpu_worker(1, 8, 0.1), gpu_worker(2, 16.0, 0.3)];
362 let req = gpu_requirements();
363 let assigned = WorkloadBalancer::assign_task(&workers, &req);
364 assert_eq!(assigned, Some(NodeId::new(2)));
365 }
366
367 #[test]
368 fn test_migration_planner_suggests_migrations() {
369 let workers = vec![
370 cpu_worker(1, 8, 0.9), cpu_worker(2, 8, 0.1), ];
373 let migrations = MigrationPlanner::plan(&workers, 0.7);
374 assert_eq!(migrations.len(), 1);
375 assert_eq!(migrations[0].from_node, NodeId::new(1));
376 assert_eq!(migrations[0].to_node, NodeId::new(2));
377 assert_eq!(migrations[0].reason, MigrationReason::LoadBalance);
378 }
379
380 #[test]
381 fn test_migration_planner_no_migrations_balanced() {
382 let workers = vec![cpu_worker(1, 8, 0.5), cpu_worker(2, 8, 0.5)];
383 let migrations = MigrationPlanner::plan(&workers, 0.7);
384 assert!(migrations.is_empty());
385 }
386}