oximedia_distributed/
worker_draining.rs1use std::collections::{HashMap, HashSet};
11use std::time::{Duration, Instant};
12use thiserror::Error;
13
14#[derive(Debug, Error, PartialEq, Eq)]
16pub enum DrainError {
17 #[error("worker '{0}' not found")]
19 WorkerNotFound(String),
20
21 #[error("worker '{0}' is already draining")]
23 AlreadyDraining(String),
24
25 #[error("worker '{0}' is not in draining state")]
27 NotDraining(String),
28
29 #[error("worker '{0}' is draining and cannot accept new tasks")]
31 WorkerDraining(String),
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum WorkerState {
37 Active,
39 Draining,
41 Drained,
43 Offline,
45}
46
47#[derive(Debug)]
49struct WorkerRecord {
50 state: WorkerState,
51 in_flight: HashSet<String>,
52 drain_started: Option<Instant>,
53}
54
55#[derive(Debug, Default)]
61pub struct DrainManager {
62 workers: HashMap<String, WorkerRecord>,
63}
64
65impl DrainManager {
66 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn register(&mut self, worker_id: impl Into<String>) {
73 self.workers.insert(
74 worker_id.into(),
75 WorkerRecord {
76 state: WorkerState::Active,
77 in_flight: HashSet::new(),
78 drain_started: None,
79 },
80 );
81 }
82
83 pub fn deregister(&mut self, worker_id: &str) -> bool {
85 self.workers.remove(worker_id).is_some()
86 }
87
88 pub fn assign_task(
93 &mut self,
94 worker_id: &str,
95 task_id: impl Into<String>,
96 ) -> Result<(), DrainError> {
97 let record = self
98 .workers
99 .get_mut(worker_id)
100 .ok_or_else(|| DrainError::WorkerNotFound(worker_id.to_owned()))?;
101
102 if record.state != WorkerState::Active {
103 return Err(DrainError::WorkerDraining(worker_id.to_owned()));
104 }
105
106 record.in_flight.insert(task_id.into());
107 Ok(())
108 }
109
110 pub fn complete_task(&mut self, worker_id: &str, task_id: &str) -> Result<(), DrainError> {
115 let record = self
116 .workers
117 .get_mut(worker_id)
118 .ok_or_else(|| DrainError::WorkerNotFound(worker_id.to_owned()))?;
119
120 record.in_flight.remove(task_id);
121
122 if record.state == WorkerState::Draining && record.in_flight.is_empty() {
124 record.state = WorkerState::Drained;
125 }
126
127 Ok(())
128 }
129
130 pub fn start_drain(&mut self, worker_id: &str) -> Result<(), DrainError> {
135 let record = self
136 .workers
137 .get_mut(worker_id)
138 .ok_or_else(|| DrainError::WorkerNotFound(worker_id.to_owned()))?;
139
140 if record.state == WorkerState::Draining || record.state == WorkerState::Drained {
141 return Err(DrainError::AlreadyDraining(worker_id.to_owned()));
142 }
143
144 record.drain_started = Some(Instant::now());
145
146 if record.in_flight.is_empty() {
147 record.state = WorkerState::Drained;
148 } else {
149 record.state = WorkerState::Draining;
150 }
151
152 Ok(())
153 }
154
155 pub fn force_drain(&mut self, worker_id: &str) -> Result<Vec<String>, DrainError> {
157 let record = self
158 .workers
159 .get_mut(worker_id)
160 .ok_or_else(|| DrainError::WorkerNotFound(worker_id.to_owned()))?;
161
162 let discarded: Vec<String> = record.in_flight.drain().collect();
163 record.state = WorkerState::Drained;
164 Ok(discarded)
165 }
166
167 pub fn state(&self, worker_id: &str) -> Option<&WorkerState> {
169 self.workers.get(worker_id).map(|r| &r.state)
170 }
171
172 pub fn in_flight_count(&self, worker_id: &str) -> Option<usize> {
174 self.workers.get(worker_id).map(|r| r.in_flight.len())
175 }
176
177 pub fn drain_elapsed(&self, worker_id: &str) -> Option<Duration> {
179 self.workers
180 .get(worker_id)
181 .and_then(|r| r.drain_started.map(|t| t.elapsed()))
182 }
183
184 pub fn timed_out_drains(&self, timeout: Duration) -> Vec<String> {
188 self.workers
189 .iter()
190 .filter_map(|(id, r)| {
191 if r.state == WorkerState::Draining {
192 r.drain_started
193 .filter(|t| t.elapsed() >= timeout)
194 .map(|_| id.clone())
195 } else {
196 None
197 }
198 })
199 .collect()
200 }
201
202 pub fn active_workers(&self) -> Vec<String> {
204 let mut ids: Vec<String> = self
205 .workers
206 .iter()
207 .filter(|(_, r)| r.state == WorkerState::Active)
208 .map(|(id, _)| id.clone())
209 .collect();
210 ids.sort();
211 ids
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn manager_with_two_workers() -> DrainManager {
220 let mut m = DrainManager::new();
221 m.register("w1");
222 m.register("w2");
223 m
224 }
225
226 #[test]
227 fn test_register_and_active_state() {
228 let m = manager_with_two_workers();
229 assert_eq!(m.state("w1"), Some(&WorkerState::Active));
230 }
231
232 #[test]
233 fn test_assign_task_success() {
234 let mut m = manager_with_two_workers();
235 m.assign_task("w1", "t1").unwrap();
236 assert_eq!(m.in_flight_count("w1"), Some(1));
237 }
238
239 #[test]
240 fn test_assign_task_to_draining_fails() {
241 let mut m = manager_with_two_workers();
242 m.assign_task("w1", "t1").unwrap();
243 m.start_drain("w1").unwrap();
244 assert!(matches!(
245 m.assign_task("w1", "t2").unwrap_err(),
246 DrainError::WorkerDraining(_)
247 ));
248 }
249
250 #[test]
251 fn test_drain_with_no_tasks_immediately_drained() {
252 let mut m = manager_with_two_workers();
253 m.start_drain("w1").unwrap();
254 assert_eq!(m.state("w1"), Some(&WorkerState::Drained));
255 }
256
257 #[test]
258 fn test_drain_completes_when_tasks_finish() {
259 let mut m = manager_with_two_workers();
260 m.assign_task("w1", "t1").unwrap();
261 m.assign_task("w1", "t2").unwrap();
262 m.start_drain("w1").unwrap();
263 assert_eq!(m.state("w1"), Some(&WorkerState::Draining));
264 m.complete_task("w1", "t1").unwrap();
265 assert_eq!(m.state("w1"), Some(&WorkerState::Draining));
266 m.complete_task("w1", "t2").unwrap();
267 assert_eq!(m.state("w1"), Some(&WorkerState::Drained));
268 }
269
270 #[test]
271 fn test_already_draining_error() {
272 let mut m = manager_with_two_workers();
273 m.assign_task("w1", "t1").unwrap();
274 m.start_drain("w1").unwrap();
275 assert!(matches!(
276 m.start_drain("w1").unwrap_err(),
277 DrainError::AlreadyDraining(_)
278 ));
279 }
280
281 #[test]
282 fn test_force_drain_discards_tasks() {
283 let mut m = manager_with_two_workers();
284 m.assign_task("w1", "t1").unwrap();
285 m.assign_task("w1", "t2").unwrap();
286 m.start_drain("w1").unwrap();
287 let discarded = m.force_drain("w1").unwrap();
288 assert_eq!(discarded.len(), 2);
289 assert_eq!(m.state("w1"), Some(&WorkerState::Drained));
290 assert_eq!(m.in_flight_count("w1"), Some(0));
291 }
292
293 #[test]
294 fn test_deregister() {
295 let mut m = manager_with_two_workers();
296 assert!(m.deregister("w1"));
297 assert!(m.state("w1").is_none());
298 assert!(!m.deregister("ghost"));
299 }
300
301 #[test]
302 fn test_active_workers_excludes_draining() {
303 let mut m = manager_with_two_workers();
304 m.assign_task("w1", "t1").unwrap();
305 m.start_drain("w1").unwrap();
306 let active = m.active_workers();
307 assert_eq!(active, vec!["w2"]);
308 }
309
310 #[test]
311 fn test_unknown_worker_errors() {
312 let mut m = manager_with_two_workers();
313 assert!(matches!(
314 m.assign_task("ghost", "t").unwrap_err(),
315 DrainError::WorkerNotFound(_)
316 ));
317 assert!(matches!(
318 m.start_drain("ghost").unwrap_err(),
319 DrainError::WorkerNotFound(_)
320 ));
321 }
322}