1use crate::ExecutionPlan;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorkerInfo {
16 pub id: String,
18 pub name: String,
21 pub tags: Vec<String>,
24 pub gpu: bool,
26 pub cpu_cores: usize,
28 pub active_jobs: usize,
30 pub max_concurrent: usize,
33}
34
35impl WorkerInfo {
36 pub fn available_slots(&self) -> usize {
40 self.max_concurrent.saturating_sub(self.active_jobs)
41 }
42
43 pub fn has_capacity(&self) -> bool {
46 self.available_slots() > 0
47 }
48
49 pub fn matches_tag(&self, tag: &str) -> bool {
51 self.tags.iter().any(|t| t == tag)
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Assignment {
58 pub node_id: String,
60 pub worker_id: String,
62 pub worker_name: String,
65 pub phase: Phase,
67 pub reason: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75#[serde(rename_all = "snake_case")]
76pub enum Phase {
77 Sequential,
80 Parallel,
82 Trial {
84 trial_index: usize,
86 total: usize,
88 },
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct DistributionPlan {
94 pub assignments: Vec<Assignment>,
96 pub phases: Vec<PlanPhase>,
98 pub data_transfers: Vec<DataTransfer>,
101 pub warnings: Vec<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct PlanPhase {
110 pub phase_index: usize,
112 pub phase_type: Phase,
115 pub node_ids: Vec<String>,
117 pub worker_ids: Vec<String>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct DataTransfer {
125 pub from_node: String,
127 pub to_node: String,
129 pub from_worker: String,
131 pub to_worker: String,
133 pub transfer_type: String,
135}
136
137struct ScheduleState<'a> {
139 workers: Vec<&'a WorkerInfo>,
140 diff_nodes: &'a [String],
141 assignments: Vec<Assignment>,
142 phases: Vec<PlanPhase>,
143 transfers: Vec<DataTransfer>,
144 warnings: Vec<String>,
145 phase_index: usize,
146}
147
148pub fn schedule(
150 plan: &ExecutionPlan,
151 workers: &[WorkerInfo],
152 differentiable_nodes: &[String],
153) -> DistributionPlan {
154 let mut state = ScheduleState {
155 workers: Vec::new(),
156 diff_nodes: differentiable_nodes,
157 assignments: Vec::new(),
158 phases: Vec::new(),
159 transfers: Vec::new(),
160 warnings: Vec::new(),
161 phase_index: 0,
162 };
163
164 if workers.is_empty() {
165 state
166 .warnings
167 .push("No workers available — will execute locally".into());
168 return DistributionPlan {
169 assignments: state.assignments,
170 phases: state.phases,
171 data_transfers: state.transfers,
172 warnings: state.warnings,
173 };
174 }
175
176 state.workers = workers.iter().filter(|w| w.has_capacity()).collect();
177 if state.workers.is_empty() {
178 state.warnings.push("All workers are at capacity".into());
179 return DistributionPlan {
180 assignments: state.assignments,
181 phases: state.phases,
182 data_transfers: state.transfers,
183 warnings: state.warnings,
184 };
185 }
186
187 schedule_plan(plan, &mut state, None);
188
189 DistributionPlan {
190 assignments: state.assignments,
191 phases: state.phases,
192 data_transfers: state.transfers,
193 warnings: state.warnings,
194 }
195}
196
197fn schedule_plan(plan: &ExecutionPlan, state: &mut ScheduleState<'_>, forced_worker: Option<&str>) {
198 match plan {
199 ExecutionPlan::Execute { node_id } | ExecutionPlan::Step { node_id, .. } => {
204 let worker = if let Some(fw) = forced_worker {
205 state
206 .workers
207 .iter()
208 .find(|w| w.id == fw)
209 .unwrap_or(&state.workers[0])
210 } else {
211 least_loaded(&state.workers)
212 };
213
214 state.assignments.push(Assignment {
215 node_id: node_id.clone(),
216 worker_id: worker.id.clone(),
217 worker_name: worker.name.clone(),
218 phase: Phase::Sequential,
219 reason: if forced_worker.is_some() {
220 "grouped with differentiable neighbors".into()
221 } else {
222 "least loaded worker".into()
223 },
224 });
225 }
226
227 ExecutionPlan::Sequence(steps) => {
228 let worker = forced_worker
229 .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
230 .unwrap_or_else(|| least_loaded(&state.workers));
231
232 let node_ids = collect_node_ids(plan);
233 let has_diff = node_ids.iter().any(|n| state.diff_nodes.contains(n));
234 let force = if has_diff {
235 Some(worker.id.as_str())
236 } else {
237 forced_worker
238 };
239
240 state.phases.push(PlanPhase {
241 phase_index: state.phase_index,
242 phase_type: Phase::Sequential,
243 node_ids: node_ids.clone(),
244 worker_ids: vec![worker.id.clone()],
245 });
246 state.phase_index += 1;
247
248 for step in steps {
249 schedule_plan(step, state, force);
250 }
251 }
252
253 ExecutionPlan::Parallel(branches) => {
254 let branch_ids: Vec<Vec<String>> = branches.iter().map(collect_node_ids).collect();
255 let mut assigned_workers = Vec::new();
256
257 for (i, branch) in branches.iter().enumerate() {
258 let worker_idx = i % state.workers.len();
259 let worker = state.workers[worker_idx];
260 assigned_workers.push(worker.id.clone());
261
262 let worker_id = worker.id.clone();
263 schedule_plan(branch, state, Some(&worker_id));
264
265 if let Some(prev) = state
267 .assignments
268 .iter()
269 .rev()
270 .find(|a| !branch_ids[i].contains(&a.node_id))
271 .filter(|prev| prev.worker_id != state.workers[worker_idx].id)
272 {
273 state.transfers.push(DataTransfer {
274 from_node: prev.node_id.clone(),
275 to_node: branch_ids[i].first().cloned().unwrap_or_default(),
276 from_worker: prev.worker_id.clone(),
277 to_worker: state.workers[worker_idx].id.clone(),
278 transfer_type: "s3".into(),
279 });
280 }
281 }
282
283 state.phases.push(PlanPhase {
284 phase_index: state.phase_index,
285 phase_type: Phase::Parallel,
286 node_ids: branch_ids.into_iter().flatten().collect(),
287 worker_ids: assigned_workers,
288 });
289 state.phase_index += 1;
290 }
291
292 ExecutionPlan::Remote { plan, .. } => {
293 schedule_plan(plan, state, None);
294 }
295
296 ExecutionPlan::Loop { body, node_id, .. } => {
297 let worker = forced_worker
298 .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
299 .unwrap_or_else(|| least_loaded(&state.workers));
300 state.assignments.push(Assignment {
301 node_id: node_id.clone(),
302 worker_id: worker.id.clone(),
303 worker_name: worker.name.clone(),
304 phase: Phase::Sequential,
305 reason: "loop controller".into(),
306 });
307 let worker_id = worker.id.clone();
308 schedule_plan(body, state, Some(&worker_id));
309 }
310
311 ExecutionPlan::Branch { node_id, arms, .. } => {
312 let worker = forced_worker
313 .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
314 .unwrap_or_else(|| least_loaded(&state.workers));
315 state.assignments.push(Assignment {
316 node_id: node_id.clone(),
317 worker_id: worker.id.clone(),
318 worker_name: worker.name.clone(),
319 phase: Phase::Sequential,
320 reason: "branch condition".into(),
321 });
322 let worker_id = worker.id.clone();
323 for (_, arm_plan) in arms {
324 schedule_plan(arm_plan, state, Some(&worker_id));
325 }
326 }
327
328 ExecutionPlan::Composite { node_ids } => {
329 let worker = forced_worker
330 .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
331 .unwrap_or_else(|| least_loaded(&state.workers));
332
333 state.phases.push(PlanPhase {
334 phase_index: state.phase_index,
335 phase_type: Phase::Sequential,
336 node_ids: node_ids.clone(),
337 worker_ids: vec![worker.id.clone()],
338 });
339 state.phase_index += 1;
340
341 let worker_id = worker.id.clone();
342 for nid in node_ids {
343 state.assignments.push(Assignment {
344 node_id: nid.clone(),
345 worker_id: worker.id.clone(),
346 worker_name: worker.name.clone(),
347 phase: Phase::Sequential,
348 reason: "composite block — same worker for gradient flow".into(),
349 });
350 }
351 drop(worker_id);
352 }
353
354 ExecutionPlan::Stream { node_ids, .. } => {
355 let worker = forced_worker
357 .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
358 .unwrap_or_else(|| least_loaded(&state.workers));
359
360 state.phases.push(PlanPhase {
361 phase_index: state.phase_index,
362 phase_type: Phase::Sequential,
363 node_ids: node_ids.clone(),
364 worker_ids: vec![worker.id.clone()],
365 });
366 state.phase_index += 1;
367
368 for nid in node_ids {
369 state.assignments.push(Assignment {
370 node_id: nid.clone(),
371 worker_id: worker.id.clone(),
372 worker_name: worker.name.clone(),
373 phase: Phase::Sequential,
374 reason: "stream block — same worker for stateful chunk processing".into(),
375 });
376 }
377 }
378
379 ExecutionPlan::Empty => {}
380 }
381}
382
383fn least_loaded<'a>(workers: &[&'a WorkerInfo]) -> &'a WorkerInfo {
389 workers
390 .iter()
391 .max_by_key(|w| w.available_slots())
392 .expect("schedule() filters out an empty worker set before placing")
393}
394
395fn collect_node_ids(plan: &ExecutionPlan) -> Vec<String> {
396 plan.node_ids().into_iter().map(|s| s.to_string()).collect()
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn test_workers() -> Vec<WorkerInfo> {
404 vec![
405 WorkerInfo {
406 id: "w1".into(),
407 name: "GPU-A100".into(),
408 tags: vec!["gpu".into()],
409 gpu: true,
410 cpu_cores: 16,
411 active_jobs: 0,
412 max_concurrent: 4,
413 },
414 WorkerInfo {
415 id: "w2".into(),
416 name: "CPU-Server".into(),
417 tags: vec!["cpu".into()],
418 gpu: false,
419 cpu_cores: 64,
420 active_jobs: 1,
421 max_concurrent: 8,
422 },
423 ]
424 }
425
426 #[test]
427 fn sequential_same_worker() {
428 let plan = ExecutionPlan::Sequence(vec![
429 ExecutionPlan::Execute {
430 node_id: "normalize".into(),
431 },
432 ExecutionPlan::Execute {
433 node_id: "select".into(),
434 },
435 ExecutionPlan::Execute {
436 node_id: "classify".into(),
437 },
438 ]);
439
440 let result = schedule(&plan, &test_workers(), &[]);
441 let worker_ids: Vec<&str> = result
443 .assignments
444 .iter()
445 .map(|a| a.worker_id.as_str())
446 .collect();
447 assert!(worker_ids.windows(2).all(|w| w[0] == w[1]));
448 }
449
450 #[test]
451 fn parallel_distributes() {
452 let plan = ExecutionPlan::Parallel(vec![
453 ExecutionPlan::Execute {
454 node_id: "train_svm".into(),
455 },
456 ExecutionPlan::Execute {
457 node_id: "train_knn".into(),
458 },
459 ]);
460
461 let result = schedule(&plan, &test_workers(), &[]);
462 assert_eq!(result.assignments.len(), 2);
463 assert_ne!(
465 result.assignments[0].worker_id,
466 result.assignments[1].worker_id
467 );
468 }
469
470 #[test]
471 fn no_workers_warns() {
472 let plan = ExecutionPlan::Execute {
473 node_id: "test".into(),
474 };
475 let result = schedule(&plan, &[], &[]);
476 assert!(!result.warnings.is_empty());
477 }
478
479 #[test]
480 fn sequence_then_parallel() {
481 let plan = ExecutionPlan::Sequence(vec![
482 ExecutionPlan::Execute {
483 node_id: "load".into(),
484 },
485 ExecutionPlan::Execute {
486 node_id: "normalize".into(),
487 },
488 ExecutionPlan::Parallel(vec![
489 ExecutionPlan::Execute {
490 node_id: "train_a".into(),
491 },
492 ExecutionPlan::Execute {
493 node_id: "train_b".into(),
494 },
495 ]),
496 ]);
497
498 let result = schedule(&plan, &test_workers(), &[]);
499 assert!(result.assignments.len() >= 4);
501 assert_eq!(
502 result.assignments[0].worker_id,
503 result.assignments[1].worker_id
504 );
505 }
506
507 #[test]
508 fn data_transfer_on_split() {
509 let plan = ExecutionPlan::Sequence(vec![
510 ExecutionPlan::Execute {
511 node_id: "preprocess".into(),
512 },
513 ExecutionPlan::Parallel(vec![
514 ExecutionPlan::Execute {
515 node_id: "branch_a".into(),
516 },
517 ExecutionPlan::Execute {
518 node_id: "branch_b".into(),
519 },
520 ]),
521 ]);
522
523 let result = schedule(&plan, &test_workers(), &[]);
524 assert!(
526 !result.data_transfers.is_empty()
527 || result
528 .assignments
529 .iter()
530 .all(|a| a.worker_id == result.assignments[0].worker_id)
531 );
532 }
533}