Skip to main content

rpytest_daemon/
scheduler.rs

1//! Test scheduler for load balancing across workers.
2
3use crate::models::ScheduledTest;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Scheduler for ordering tests based on duration estimates.
8///
9/// Strategy: Schedule longest tests first (LPT - Longest Processing Time).
10/// This helps minimize total execution time by ensuring slow tests
11/// start early while faster tests fill in gaps.
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct TestScheduler {
14    /// Duration history for each test (node_id -> list of durations in ms)
15    pub duration_history: HashMap<String, Vec<u64>>,
16    /// Default duration estimate in ms when no history exists
17    pub default_duration_ms: u64,
18}
19
20impl TestScheduler {
21    /// Create a new scheduler.
22    pub fn new() -> Self {
23        TestScheduler {
24            duration_history: HashMap::new(),
25            default_duration_ms: 1000, // 1 second default
26        }
27    }
28
29    /// Update duration history for a test.
30    pub fn update_duration(&mut self, node_id: &str, duration_ms: u64) {
31        let history = self
32            .duration_history
33            .entry(node_id.to_string())
34            .or_default();
35        history.push(duration_ms);
36
37        // Keep only last 10 runs
38        if history.len() > 10 {
39            *history = history[history.len() - 10..].to_vec();
40        }
41    }
42
43    /// Get estimated duration for a test.
44    pub fn get_estimated_duration(&self, node_id: &str) -> u64 {
45        if let Some(durations) = self.duration_history.get(node_id) {
46            if durations.is_empty() {
47                return self.default_duration_ms;
48            }
49
50            if durations.len() == 1 {
51                return durations[0];
52            }
53
54            // Use exponential moving average favoring recent runs
55            let len = durations.len();
56            let mut weighted_sum: f64 = 0.0;
57            let mut total_weight: f64 = 0.0;
58
59            for (i, &duration) in durations.iter().enumerate() {
60                // Most recent gets highest weight (reversed index)
61                let weight = 0.5_f64.powi((len - 1 - i) as i32);
62                weighted_sum += duration as f64 * weight;
63                total_weight += weight;
64            }
65
66            (weighted_sum / total_weight) as u64
67        } else {
68            self.default_duration_ms
69        }
70    }
71
72    /// Schedule tests for optimal execution order.
73    ///
74    /// Args:
75    ///   node_ids: Tests to schedule.
76    ///   failed_first: If true, prioritize recently failed tests.
77    ///   recent_failures: List of node IDs that failed recently.
78    ///
79    /// Returns:
80    ///   Ordered list of node IDs optimized for parallel execution.
81    pub fn schedule(
82        &self,
83        node_ids: &[String],
84        failed_first: bool,
85        recent_failures: &[String],
86    ) -> Vec<String> {
87        if node_ids.is_empty() {
88            return Vec::new();
89        }
90
91        let recent_failures_set: std::collections::HashSet<&str> =
92            recent_failures.iter().map(|s| s.as_str()).collect();
93
94        // Create scheduled test objects
95        let mut scheduled: Vec<ScheduledTest> = Vec::with_capacity(node_ids.len());
96
97        for node_id in node_ids {
98            let est_duration = self.get_estimated_duration(node_id);
99
100            // Calculate priority
101            let mut priority = est_duration;
102
103            if failed_first && recent_failures_set.contains(node_id.as_str()) {
104                // Boost priority for recently failed tests
105                priority += 1_000_000;
106            }
107
108            scheduled.push(ScheduledTest {
109                node_id: node_id.clone(),
110                estimated_duration_ms: est_duration,
111                priority,
112            });
113        }
114
115        // Sort by priority descending (highest first)
116        scheduled.sort_by(|a, b| b.priority.cmp(&a.priority));
117
118        scheduled.into_iter().map(|s| s.node_id).collect()
119    }
120
121    /// Split tests into N balanced batches using LPT (Longest Processing Time) algorithm.
122    ///
123    /// This distributes tests across workers to minimize total execution time by:
124    /// 1. Sorting tests by estimated duration (longest first)
125    /// 2. Greedily assigning each test to the batch with the lowest total duration
126    ///
127    /// Returns a vector of batches, where each batch is a vector of node IDs.
128    pub fn split_balanced(&self, node_ids: &[String], worker_count: usize) -> Vec<Vec<String>> {
129        if worker_count <= 1 || node_ids.is_empty() {
130            return vec![node_ids.to_vec()];
131        }
132
133        // Sort by duration descending
134        let mut sorted: Vec<_> = node_ids
135            .iter()
136            .map(|id| (id.clone(), self.get_estimated_duration(id)))
137            .collect();
138        sorted.sort_by(|a, b| b.1.cmp(&a.1));
139
140        // Greedy assignment to batch with lowest total duration
141        let mut batches: Vec<Vec<String>> = vec![Vec::new(); worker_count];
142        let mut batch_durations = vec![0u64; worker_count];
143
144        for (test, duration) in sorted {
145            // Find the batch with the minimum total duration
146            let min_idx = batch_durations
147                .iter()
148                .enumerate()
149                .min_by_key(|(_, d)| *d)
150                .map(|(i, _)| i)
151                .unwrap_or(0);
152
153            batches[min_idx].push(test);
154            batch_durations[min_idx] += duration;
155        }
156
157        // Filter out empty batches
158        batches.into_iter().filter(|b| !b.is_empty()).collect()
159    }
160
161    /// Clear duration history.
162    pub fn clear_history(&mut self) {
163        self.duration_history.clear();
164    }
165
166    /// Get history for a specific test.
167    pub fn get_history(&self, node_id: &str) -> Option<&Vec<u64>> {
168        self.duration_history.get(node_id)
169    }
170
171    /// Get total number of tracked tests.
172    pub fn tracked_count(&self) -> usize {
173        self.duration_history.len()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_schedule_ordering() {
183        let scheduler = TestScheduler::new();
184        let node_ids = vec![
185            "test_a".to_string(),
186            "test_b".to_string(),
187            "test_c".to_string(),
188        ];
189
190        // Without history, should maintain order
191        let result = scheduler.schedule(&node_ids, false, &[]);
192        assert_eq!(result, node_ids);
193    }
194
195    #[test]
196    fn test_failed_first_priority() {
197        let scheduler = TestScheduler::new();
198        let node_ids = vec![
199            "test_a".to_string(),
200            "test_b".to_string(),
201            "test_c".to_string(),
202        ];
203        let recent_failures = vec!["test_b".to_string()];
204
205        let result = scheduler.schedule(&node_ids, true, &recent_failures);
206        // test_b should be first
207        assert_eq!(result[0], "test_b");
208    }
209
210    #[test]
211    fn test_duration_update() {
212        let mut scheduler = TestScheduler::new();
213        scheduler.update_duration("test_a", 100);
214        scheduler.update_duration("test_a", 200);
215
216        let duration = scheduler.get_estimated_duration("test_a");
217        // Should be around 175 (weighted average favoring recent)
218        assert!(duration >= 150 && duration <= 200);
219    }
220
221    #[test]
222    fn test_split_balanced_empty() {
223        let scheduler = TestScheduler::new();
224        let result = scheduler.split_balanced(&[], 4);
225        assert_eq!(result.len(), 1);
226        assert!(result[0].is_empty());
227    }
228
229    #[test]
230    fn test_split_balanced_single_worker() {
231        let scheduler = TestScheduler::new();
232        let node_ids = vec!["a".to_string(), "b".to_string(), "c".to_string()];
233        let result = scheduler.split_balanced(&node_ids, 1);
234        assert_eq!(result.len(), 1);
235        assert_eq!(result[0].len(), 3);
236    }
237
238    #[test]
239    fn test_split_balanced_even_distribution() {
240        let mut scheduler = TestScheduler::new();
241        // Create tests with known durations
242        scheduler.update_duration("slow", 1000);
243        scheduler.update_duration("medium1", 500);
244        scheduler.update_duration("medium2", 500);
245        scheduler.update_duration("fast1", 100);
246        scheduler.update_duration("fast2", 100);
247
248        let node_ids = vec![
249            "slow".to_string(),
250            "medium1".to_string(),
251            "medium2".to_string(),
252            "fast1".to_string(),
253            "fast2".to_string(),
254        ];
255
256        let batches = scheduler.split_balanced(&node_ids, 2);
257        assert_eq!(batches.len(), 2);
258
259        // Calculate total duration per batch
260        let batch1_duration: u64 = batches[0]
261            .iter()
262            .map(|id| scheduler.get_estimated_duration(id))
263            .sum();
264        let batch2_duration: u64 = batches[1]
265            .iter()
266            .map(|id| scheduler.get_estimated_duration(id))
267            .sum();
268
269        // Batches should be reasonably balanced (within 50% of each other)
270        let max_dur = batch1_duration.max(batch2_duration);
271        let min_dur = batch1_duration.min(batch2_duration);
272        assert!(max_dur <= min_dur * 2, "Batches not balanced: {} vs {}", batch1_duration, batch2_duration);
273    }
274}