Skip to main content

oxigeo_distributed/
coordinator.rs

1//! Coordinator for managing distributed task execution.
2//!
3//! This module implements the coordinator that schedules tasks across worker nodes,
4//! monitors progress, and aggregates results.
5
6use crate::error::{DistributedError, Result};
7use crate::flight::FlightClient;
8use crate::task::{PartitionId, Task, TaskId, TaskOperation, TaskResult, TaskScheduler};
9use crate::worker::WorkerStatus;
10use arrow::record_batch::RecordBatch;
11use std::collections::HashMap;
12use std::sync::{Arc, RwLock};
13use std::time::{Duration, Instant};
14use tokio::sync::mpsc;
15use tracing::{debug, error, info, warn};
16
17/// Coordinator configuration.
18#[derive(Debug, Clone)]
19pub struct CoordinatorConfig {
20    /// Listen address for Flight server.
21    pub listen_addr: String,
22    /// Maximum task retry attempts.
23    pub max_retries: u32,
24    /// Task timeout in seconds.
25    pub task_timeout_secs: u64,
26    /// Worker heartbeat timeout in seconds.
27    pub worker_timeout_secs: u64,
28    /// Result buffer size.
29    pub result_buffer_size: usize,
30}
31
32impl CoordinatorConfig {
33    /// Create a new coordinator configuration.
34    pub fn new(listen_addr: String) -> Self {
35        Self {
36            listen_addr,
37            max_retries: 3,
38            task_timeout_secs: 300, // 5 minutes
39            worker_timeout_secs: 60,
40            result_buffer_size: 1000,
41        }
42    }
43
44    /// Set the maximum retry attempts.
45    pub fn with_max_retries(mut self, retries: u32) -> Self {
46        self.max_retries = retries;
47        self
48    }
49
50    /// Set the task timeout.
51    pub fn with_task_timeout(mut self, timeout_secs: u64) -> Self {
52        self.task_timeout_secs = timeout_secs;
53        self
54    }
55}
56
57/// Information about a connected worker.
58#[derive(Debug, Clone)]
59pub struct WorkerInfo {
60    /// Worker identifier.
61    pub worker_id: String,
62    /// Worker address.
63    pub address: String,
64    /// Current status.
65    pub status: WorkerStatus,
66    /// Last heartbeat timestamp.
67    pub last_heartbeat: Instant,
68    /// Number of active tasks.
69    pub active_tasks: usize,
70    /// Total tasks completed.
71    pub completed_tasks: u64,
72    /// Total tasks failed.
73    pub failed_tasks: u64,
74}
75
76impl WorkerInfo {
77    /// Create new worker info.
78    pub fn new(worker_id: String, address: String) -> Self {
79        Self {
80            worker_id,
81            address,
82            status: WorkerStatus::Idle,
83            last_heartbeat: Instant::now(),
84            active_tasks: 0,
85            completed_tasks: 0,
86            failed_tasks: 0,
87        }
88    }
89
90    /// Update heartbeat timestamp.
91    pub fn update_heartbeat(&mut self) {
92        self.last_heartbeat = Instant::now();
93    }
94
95    /// Check if the worker has timed out.
96    pub fn is_timed_out(&self, timeout: Duration) -> bool {
97        self.last_heartbeat.elapsed() > timeout
98    }
99
100    /// Get the success rate.
101    pub fn success_rate(&self) -> f64 {
102        let total = self.completed_tasks + self.failed_tasks;
103        if total == 0 {
104            1.0
105        } else {
106            self.completed_tasks as f64 / total as f64
107        }
108    }
109}
110
111/// Coordinator for distributed task execution.
112pub struct Coordinator {
113    /// Coordinator configuration.
114    config: CoordinatorConfig,
115    /// Task scheduler.
116    scheduler: Arc<RwLock<TaskScheduler>>,
117    /// Connected workers.
118    workers: Arc<RwLock<HashMap<String, WorkerInfo>>>,
119    /// Task assignments (task_id -> worker_id).
120    assignments: Arc<RwLock<HashMap<TaskId, String>>>,
121    /// Task results.
122    results: Arc<RwLock<HashMap<TaskId, TaskResult>>>,
123    /// Task counter for generating unique IDs.
124    next_task_id: Arc<RwLock<u64>>,
125}
126
127impl Coordinator {
128    /// Create a new coordinator.
129    pub fn new(config: CoordinatorConfig) -> Self {
130        Self {
131            config,
132            scheduler: Arc::new(RwLock::new(TaskScheduler::new())),
133            workers: Arc::new(RwLock::new(HashMap::new())),
134            assignments: Arc::new(RwLock::new(HashMap::new())),
135            results: Arc::new(RwLock::new(HashMap::new())),
136            next_task_id: Arc::new(RwLock::new(0)),
137        }
138    }
139
140    /// Add a worker to the coordinator.
141    pub fn add_worker(&self, worker_id: String, address: String) -> Result<()> {
142        info!("Adding worker: {} at {}", worker_id, address);
143
144        let worker_info = WorkerInfo::new(worker_id.clone(), address);
145
146        let mut workers = self
147            .workers
148            .write()
149            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
150
151        if workers.contains_key(&worker_id) {
152            return Err(DistributedError::coordinator(format!(
153                "Worker {} already exists",
154                worker_id
155            )));
156        }
157
158        workers.insert(worker_id, worker_info);
159        Ok(())
160    }
161
162    /// Remove a worker from the coordinator.
163    pub fn remove_worker(&self, worker_id: &str) -> Result<()> {
164        info!("Removing worker: {}", worker_id);
165
166        let mut workers = self
167            .workers
168            .write()
169            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
170
171        workers.remove(worker_id);
172
173        // Reassign tasks from this worker
174        self.reassign_worker_tasks(worker_id)?;
175
176        Ok(())
177    }
178
179    /// Update worker heartbeat.
180    pub fn update_worker_heartbeat(&self, worker_id: &str) -> Result<()> {
181        let mut workers = self
182            .workers
183            .write()
184            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
185
186        if let Some(worker) = workers.get_mut(worker_id) {
187            worker.update_heartbeat();
188            debug!("Updated heartbeat for worker {}", worker_id);
189            Ok(())
190        } else {
191            Err(DistributedError::coordinator(format!(
192                "Worker {} not found",
193                worker_id
194            )))
195        }
196    }
197
198    /// Check for timed-out workers and reassign their tasks.
199    pub fn check_worker_timeouts(&self) -> Result<Vec<String>> {
200        let timeout = Duration::from_secs(self.config.worker_timeout_secs);
201        let mut timed_out = Vec::new();
202
203        let workers = self
204            .workers
205            .read()
206            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
207
208        for (worker_id, worker) in workers.iter() {
209            if worker.is_timed_out(timeout) {
210                warn!("Worker {} has timed out", worker_id);
211                timed_out.push(worker_id.clone());
212            }
213        }
214
215        drop(workers);
216
217        // Reassign tasks from timed-out workers
218        for worker_id in &timed_out {
219            self.reassign_worker_tasks(worker_id)?;
220            self.remove_worker(worker_id)?;
221        }
222
223        Ok(timed_out)
224    }
225
226    /// Submit a task for execution.
227    pub fn submit_task(
228        &self,
229        partition_id: PartitionId,
230        operation: TaskOperation,
231    ) -> Result<TaskId> {
232        let task_id = self.generate_task_id()?;
233        let mut task = Task::new(task_id, partition_id, operation);
234        task.max_retries = self.config.max_retries;
235
236        let mut scheduler = self
237            .scheduler
238            .write()
239            .map_err(|_| DistributedError::coordinator("Failed to acquire scheduler lock"))?;
240
241        scheduler.add_task(task);
242        debug!("Submitted task {}", task_id);
243
244        Ok(task_id)
245    }
246
247    /// Get the next task to execute.
248    pub fn next_task(&self) -> Result<Option<Task>> {
249        let mut scheduler = self
250            .scheduler
251            .write()
252            .map_err(|_| DistributedError::coordinator("Failed to acquire scheduler lock"))?;
253
254        Ok(scheduler.next_task())
255    }
256
257    /// Assign a task to a worker.
258    pub fn assign_task(&self, task: Task, worker_id: String) -> Result<()> {
259        // Mark task as running
260        let mut scheduler = self
261            .scheduler
262            .write()
263            .map_err(|_| DistributedError::coordinator("Failed to acquire scheduler lock"))?;
264        scheduler.mark_running(task.clone(), worker_id.clone());
265        drop(scheduler);
266
267        // Record assignment
268        let mut assignments = self
269            .assignments
270            .write()
271            .map_err(|_| DistributedError::coordinator("Failed to acquire assignments lock"))?;
272        assignments.insert(task.id, worker_id.clone());
273
274        // Update worker info
275        let mut workers = self
276            .workers
277            .write()
278            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
279        if let Some(worker) = workers.get_mut(&worker_id) {
280            worker.active_tasks += 1;
281            worker.status = WorkerStatus::Busy;
282        }
283
284        info!("Assigned task {} to worker {}", task.id, worker_id);
285        Ok(())
286    }
287
288    /// Dispatch a task to a remote worker over Arrow Flight and drive it to
289    /// completion.
290    ///
291    /// This is the end-to-end glue between the three formerly-disconnected
292    /// components: it records the assignment in local bookkeeping, opens a
293    /// [`FlightClient`] to the worker's registered address, ships the serialized
294    /// task plus its `input` partition through the `execute_task` action, and
295    /// feeds the real [`TaskResult`] returned by the worker back into
296    /// [`Coordinator::complete_task`]. The result is a genuine multi-process
297    /// pipeline rather than three independently-correct pieces that never talk.
298    pub async fn dispatch_task_to_worker(
299        &self,
300        task: Task,
301        worker_id: &str,
302        input: Arc<RecordBatch>,
303    ) -> Result<TaskResult> {
304        // Resolve the worker's network address from local bookkeeping.
305        let address = {
306            let workers = self
307                .workers
308                .read()
309                .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
310            workers
311                .get(worker_id)
312                .map(|w| w.address.clone())
313                .ok_or_else(|| {
314                    DistributedError::coordinator(format!("Worker {worker_id} not found"))
315                })?
316        };
317
318        // Record the assignment (scheduler + worker counters) before shipping.
319        self.assign_task(task.clone(), worker_id.to_string())?;
320
321        // Connect and dispatch over Flight.
322        let dispatch = async {
323            let mut client = FlightClient::new(address).await?;
324            let (response, output) = client.execute_task(&task, Some(input.as_ref())).await?;
325            Ok::<_, DistributedError>((response, output))
326        }
327        .await;
328
329        let result = match dispatch {
330            Ok((response, output)) => {
331                if response.success {
332                    match output {
333                        Some(batch) => TaskResult::success(
334                            task.id,
335                            Arc::new(batch),
336                            response.execution_time_ms,
337                        ),
338                        None => TaskResult::failure(
339                            task.id,
340                            "worker reported success but returned no output batch".to_string(),
341                            response.execution_time_ms,
342                        ),
343                    }
344                } else {
345                    TaskResult::failure(
346                        task.id,
347                        response
348                            .error
349                            .unwrap_or_else(|| "worker reported failure".to_string()),
350                        response.execution_time_ms,
351                    )
352                }
353            }
354            // A transport/connection failure is itself a task failure so the
355            // scheduler can retry or give up — never a silent success.
356            Err(e) => {
357                warn!(
358                    "Dispatch of task {} to {} failed: {}",
359                    task.id, worker_id, e
360                );
361                TaskResult::failure(task.id, e.to_string(), 0)
362            }
363        };
364
365        self.complete_task(task.id, result.clone())?;
366        Ok(result)
367    }
368
369    /// Record task completion.
370    pub fn complete_task(&self, task_id: TaskId, result: TaskResult) -> Result<()> {
371        let worker_id = {
372            let assignments = self
373                .assignments
374                .read()
375                .map_err(|_| DistributedError::coordinator("Failed to acquire assignments lock"))?;
376            assignments.get(&task_id).cloned()
377        };
378
379        // Update scheduler
380        let mut scheduler = self
381            .scheduler
382            .write()
383            .map_err(|_| DistributedError::coordinator("Failed to acquire scheduler lock"))?;
384
385        if result.is_success() {
386            scheduler.mark_completed(task_id)?;
387        } else {
388            scheduler.mark_failed(task_id)?;
389        }
390        drop(scheduler);
391
392        // Update worker info
393        if let Some(worker_id) = worker_id {
394            let mut workers = self
395                .workers
396                .write()
397                .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
398
399            if let Some(worker) = workers.get_mut(&worker_id) {
400                if worker.active_tasks > 0 {
401                    worker.active_tasks -= 1;
402                }
403                if result.is_success() {
404                    worker.completed_tasks += 1;
405                } else {
406                    worker.failed_tasks += 1;
407                }
408                if worker.active_tasks == 0 {
409                    worker.status = WorkerStatus::Idle;
410                }
411            }
412        }
413
414        // Store result
415        let mut results = self
416            .results
417            .write()
418            .map_err(|_| DistributedError::coordinator("Failed to acquire results lock"))?;
419        results.insert(task_id, result);
420
421        info!("Task {} completed", task_id);
422        Ok(())
423    }
424
425    /// Get the best available worker for a task.
426    pub fn get_available_worker(&self) -> Result<Option<String>> {
427        let workers = self
428            .workers
429            .read()
430            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
431
432        // Find idle worker with best success rate
433        let best_worker = workers
434            .values()
435            .filter(|w| w.status == WorkerStatus::Idle)
436            .max_by(|a, b| {
437                a.success_rate()
438                    .partial_cmp(&b.success_rate())
439                    .unwrap_or(std::cmp::Ordering::Equal)
440            })
441            .map(|w| w.worker_id.clone());
442
443        Ok(best_worker)
444    }
445
446    /// Get execution progress.
447    pub fn get_progress(&self) -> Result<CoordinatorProgress> {
448        let scheduler = self
449            .scheduler
450            .read()
451            .map_err(|_| DistributedError::coordinator("Failed to acquire scheduler lock"))?;
452
453        let workers = self
454            .workers
455            .read()
456            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
457
458        Ok(CoordinatorProgress {
459            pending_tasks: scheduler.pending_count(),
460            running_tasks: scheduler.running_count(),
461            completed_tasks: scheduler.completed_count(),
462            failed_tasks: scheduler.failed_count(),
463            active_workers: workers.len(),
464            idle_workers: workers
465                .values()
466                .filter(|w| w.status == WorkerStatus::Idle)
467                .count(),
468        })
469    }
470
471    /// Collect all task results.
472    pub fn collect_results(&self) -> Result<Vec<TaskResult>> {
473        let results = self
474            .results
475            .read()
476            .map_err(|_| DistributedError::coordinator("Failed to acquire results lock"))?;
477
478        Ok(results.values().cloned().collect())
479    }
480
481    /// Check if all tasks are complete.
482    pub fn is_complete(&self) -> bool {
483        self.scheduler
484            .read()
485            .map(|s| s.is_complete())
486            .unwrap_or(false)
487    }
488
489    /// Generate a unique task ID.
490    fn generate_task_id(&self) -> Result<TaskId> {
491        let mut next_id = self
492            .next_task_id
493            .write()
494            .map_err(|_| DistributedError::coordinator("Failed to acquire task ID lock"))?;
495        let id = *next_id;
496        *next_id += 1;
497        Ok(TaskId(id))
498    }
499
500    /// Reassign tasks from a specific worker.
501    fn reassign_worker_tasks(&self, worker_id: &str) -> Result<()> {
502        let mut scheduler = self
503            .scheduler
504            .write()
505            .map_err(|_| DistributedError::coordinator("Failed to acquire scheduler lock"))?;
506
507        let mut assignments = self
508            .assignments
509            .write()
510            .map_err(|_| DistributedError::coordinator("Failed to acquire assignments lock"))?;
511
512        // Find tasks assigned to this worker
513        let task_ids: Vec<TaskId> = assignments
514            .iter()
515            .filter(|(_, wid)| *wid == worker_id)
516            .map(|(tid, _)| *tid)
517            .collect();
518
519        // Mark them as failed (will be retried if possible)
520        for task_id in task_ids {
521            let _ = scheduler.mark_failed(task_id);
522            assignments.remove(&task_id);
523        }
524
525        Ok(())
526    }
527
528    /// Get list of all workers.
529    pub fn list_workers(&self) -> Result<Vec<WorkerInfo>> {
530        let workers = self
531            .workers
532            .read()
533            .map_err(|_| DistributedError::coordinator("Failed to acquire workers lock"))?;
534
535        Ok(workers.values().cloned().collect())
536    }
537
538    /// Start monitoring loop for worker health.
539    pub async fn start_monitoring(
540        self: Arc<Self>,
541        mut shutdown_rx: mpsc::Receiver<()>,
542    ) -> Result<()> {
543        info!("Starting coordinator monitoring loop");
544
545        let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(10));
546
547        loop {
548            tokio::select! {
549                _ = interval.tick() => {
550                    if let Err(e) = self.check_worker_timeouts() {
551                        error!("Error checking worker timeouts: {}", e);
552                    }
553
554                    let progress = self.get_progress().unwrap_or_default();
555                    debug!("Progress: {:?}", progress);
556                }
557                _ = shutdown_rx.recv() => {
558                    info!("Coordinator monitoring loop shutting down");
559                    break;
560                }
561            }
562        }
563
564        Ok(())
565    }
566}
567
568/// Progress information for the coordinator.
569#[derive(Debug, Clone, Default)]
570pub struct CoordinatorProgress {
571    /// Number of pending tasks.
572    pub pending_tasks: usize,
573    /// Number of running tasks.
574    pub running_tasks: usize,
575    /// Number of completed tasks.
576    pub completed_tasks: usize,
577    /// Number of failed tasks.
578    pub failed_tasks: usize,
579    /// Number of active workers.
580    pub active_workers: usize,
581    /// Number of idle workers.
582    pub idle_workers: usize,
583}
584
585impl CoordinatorProgress {
586    /// Get the total number of tasks.
587    pub fn total_tasks(&self) -> usize {
588        self.pending_tasks + self.running_tasks + self.completed_tasks + self.failed_tasks
589    }
590
591    /// Get the completion percentage.
592    pub fn completion_percentage(&self) -> f64 {
593        let total = self.total_tasks();
594        if total == 0 {
595            0.0
596        } else {
597            (self.completed_tasks as f64 / total as f64) * 100.0
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn test_coordinator_config() {
608        let config = CoordinatorConfig::new("localhost:50051".to_string())
609            .with_max_retries(5)
610            .with_task_timeout(600);
611
612        assert_eq!(config.listen_addr, "localhost:50051");
613        assert_eq!(config.max_retries, 5);
614        assert_eq!(config.task_timeout_secs, 600);
615    }
616
617    #[test]
618    fn test_worker_info() {
619        let mut info = WorkerInfo::new("worker-1".to_string(), "localhost:50052".to_string());
620
621        info.completed_tasks = 8;
622        info.failed_tasks = 2;
623
624        assert_eq!(info.success_rate(), 0.8);
625        assert!(!info.is_timed_out(Duration::from_secs(60)));
626    }
627
628    #[test]
629    fn test_coordinator_creation() -> std::result::Result<(), Box<dyn std::error::Error>> {
630        let config = CoordinatorConfig::new("localhost:50051".to_string());
631        let coordinator = Coordinator::new(config);
632
633        let progress = coordinator.get_progress()?;
634        assert_eq!(progress.total_tasks(), 0);
635        assert_eq!(progress.active_workers, 0);
636        Ok(())
637    }
638
639    #[test]
640    fn test_add_worker() -> std::result::Result<(), Box<dyn std::error::Error>> {
641        let config = CoordinatorConfig::new("localhost:50051".to_string());
642        let coordinator = Coordinator::new(config);
643
644        coordinator.add_worker("worker-1".to_string(), "localhost:50052".to_string())?;
645
646        let workers = coordinator.list_workers()?;
647        assert_eq!(workers.len(), 1);
648        assert_eq!(workers[0].worker_id, "worker-1");
649        Ok(())
650    }
651
652    #[test]
653    fn test_submit_task() -> std::result::Result<(), Box<dyn std::error::Error>> {
654        let config = CoordinatorConfig::new("localhost:50051".to_string());
655        let coordinator = Coordinator::new(config);
656
657        let task_id = coordinator.submit_task(
658            PartitionId(0),
659            TaskOperation::Filter {
660                expression: "value > 10".to_string(),
661            },
662        )?;
663
664        assert_eq!(task_id, TaskId(0));
665
666        let progress = coordinator.get_progress()?;
667        assert_eq!(progress.pending_tasks, 1);
668        Ok(())
669    }
670
671    #[test]
672    fn test_progress() {
673        let progress = CoordinatorProgress {
674            pending_tasks: 10,
675            running_tasks: 5,
676            completed_tasks: 30,
677            failed_tasks: 5,
678            active_workers: 4,
679            idle_workers: 2,
680        };
681
682        assert_eq!(progress.total_tasks(), 50);
683        assert_eq!(progress.completion_percentage(), 60.0);
684    }
685}