Skip to main content

oximedia_distributed/
task_distribution.rs

1//! Intelligent task distribution across worker nodes.
2//!
3//! Provides affinity scoring, workload balancing, and task migration planning
4//! for the distributed encoding cluster.
5
6#![allow(dead_code)]
7
8use crate::consensus::NodeId;
9
10/// Describes the capabilities of a worker node.
11#[derive(Debug, Clone)]
12pub struct WorkerCapability {
13    /// Number of CPU cores available.
14    pub cpu_cores: u32,
15    /// RAM in gigabytes.
16    pub memory_gb: f32,
17    /// GPU VRAM in gigabytes (0.0 if no GPU).
18    pub gpu_vram_gb: f32,
19    /// Network bandwidth in Mbps.
20    pub network_mbps: u32,
21    /// Arbitrary string tags (e.g., "av1", "gpu", "high-mem").
22    pub tags: Vec<String>,
23}
24
25impl WorkerCapability {
26    /// Create a new capability descriptor.
27    #[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/// Describes the resource requirements of a task.
46#[derive(Debug, Clone)]
47pub struct TaskRequirements {
48    /// Minimum CPU cores needed.
49    pub min_cpu_cores: u32,
50    /// Minimum RAM in gigabytes needed.
51    pub min_memory_gb: f32,
52    /// Whether a GPU is required.
53    pub requires_gpu: bool,
54    /// Minimum GPU VRAM in gigabytes needed (only relevant if `requires_gpu`).
55    pub min_gpu_vram_gb: f32,
56    /// Tags that are preferred (not mandatory) on the worker.
57    pub preferred_tags: Vec<String>,
58}
59
60impl TaskRequirements {
61    /// Create a new task requirements descriptor.
62    #[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
80/// Computes affinity scores between workers and tasks.
81pub struct AffinityScore;
82
83impl AffinityScore {
84    /// Compute a 0.0–1.0 affinity score.
85    ///
86    /// Scoring:
87    /// - Returns 0.0 if mandatory minimums are not met.
88    /// - Base score of 0.5 when all minimums are met.
89    /// - +0.1 for each preferred tag match (capped so total <= 1.0).
90    /// - +0.3 if GPU is required and available with sufficient VRAM.
91    #[must_use]
92    pub fn compute(capability: &WorkerCapability, requirements: &TaskRequirements) -> f32 {
93        // Check mandatory minimums
94        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        // GPU bonus
107        if requirements.requires_gpu && capability.gpu_vram_gb >= requirements.min_gpu_vram_gb {
108            score += 0.3;
109        }
110
111        // Tag bonus: +0.1 per preferred tag found on the worker
112        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/// Current status of a worker node.
123#[derive(Debug, Clone)]
124pub struct WorkerStatus {
125    /// The node identifier.
126    pub id: NodeId,
127    /// The node's capabilities.
128    pub capability: WorkerCapability,
129    /// Current load as a fraction (0.0 = idle, 1.0 = fully loaded).
130    pub load_pct: f32,
131    /// Number of tasks currently assigned.
132    pub task_count: u32,
133}
134
135impl WorkerStatus {
136    /// Create a new worker status.
137    #[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
148/// Selects the best worker for a given task based on affinity and load.
149pub struct WorkloadBalancer;
150
151impl WorkloadBalancer {
152    /// Assign a task to the worker with the highest effective score.
153    ///
154    /// Effective score = `affinity × (1 - load_pct)`.
155    /// Returns `None` if no worker can satisfy the task requirements.
156    #[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/// Reason why a task is being migrated.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum MigrationReason {
179    /// Redistribute load across workers.
180    LoadBalance,
181    /// Source node has failed.
182    NodeFailure,
183    /// Source node has exhausted resources.
184    ResourceExhausted,
185    /// User explicitly requested the migration.
186    UserRequest,
187}
188
189/// Describes a planned task migration.
190#[derive(Debug, Clone)]
191pub struct TaskMigration {
192    /// Identifier of the task being migrated.
193    pub task_id: String,
194    /// Node the task is migrating from.
195    pub from_node: NodeId,
196    /// Node the task is migrating to.
197    pub to_node: NodeId,
198    /// Reason for the migration.
199    pub reason: MigrationReason,
200    /// Amount of state data to transfer in bytes.
201    pub data_size_bytes: u64,
202}
203
204/// Plans task migrations to rebalance the cluster.
205pub struct MigrationPlanner;
206
207impl MigrationPlanner {
208    /// Produce a list of migration recommendations.
209    ///
210    /// A migration is suggested when a worker's load exceeds the threshold
211    /// and another worker's load is below `1.0 - threshold_imbalance`.
212    #[must_use]
213    pub fn plan(workers: &[WorkerStatus], threshold_imbalance: f32) -> Vec<TaskMigration> {
214        let mut migrations = Vec::new();
215
216        // Identify overloaded and underloaded workers
217        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        // 0.5 base + 0.3 gpu + 0.1 tag = 0.9
301        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        // 0.5 + 0.1 + 0.1 = 0.7
322        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), // heavily loaded
338            cpu_worker(2, 8, 0.1), // lightly loaded
339            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), // overloaded
371            cpu_worker(2, 8, 0.1), // underloaded
372        ];
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}