Skip to main content

oxigdal_distributed/
worker.rs

1//! Worker node implementation for distributed processing.
2//!
3//! This module implements worker nodes that execute geospatial processing tasks
4//! assigned by the coordinator.
5
6use crate::error::{DistributedError, Result};
7use crate::task::{Task, TaskContext, TaskId, TaskOperation, TaskResult};
8use arrow::record_batch::RecordBatch;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, RwLock};
12use std::time::Instant;
13use tokio::sync::mpsc;
14use tracing::{debug, error, info, warn};
15
16/// Worker node configuration.
17#[derive(Debug, Clone)]
18pub struct WorkerConfig {
19    /// Unique worker identifier.
20    pub worker_id: String,
21    /// Maximum number of concurrent tasks.
22    pub max_concurrent_tasks: usize,
23    /// Memory limit in bytes.
24    pub memory_limit: u64,
25    /// Number of CPU cores available.
26    pub num_cores: usize,
27    /// Heartbeat interval in seconds.
28    pub heartbeat_interval_secs: u64,
29}
30
31impl WorkerConfig {
32    /// Create a new worker configuration.
33    pub fn new(worker_id: String) -> Self {
34        let num_cores = std::thread::available_parallelism()
35            .map(|n| n.get())
36            .unwrap_or(1);
37
38        Self {
39            worker_id,
40            max_concurrent_tasks: num_cores,
41            memory_limit: 4 * 1024 * 1024 * 1024, // 4 GB default
42            num_cores,
43            heartbeat_interval_secs: 30,
44        }
45    }
46
47    /// Set the maximum number of concurrent tasks.
48    pub fn with_max_concurrent_tasks(mut self, max: usize) -> Self {
49        self.max_concurrent_tasks = max;
50        self
51    }
52
53    /// Set the memory limit.
54    pub fn with_memory_limit(mut self, limit: u64) -> Self {
55        self.memory_limit = limit;
56        self
57    }
58
59    /// Set the number of cores.
60    pub fn with_num_cores(mut self, cores: usize) -> Self {
61        self.num_cores = cores;
62        self
63    }
64}
65
66/// Worker node status.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum WorkerStatus {
69    /// Worker is idle and ready for tasks.
70    Idle,
71    /// Worker is executing tasks.
72    Busy,
73    /// Worker is shutting down.
74    ShuttingDown,
75    /// Worker is offline.
76    Offline,
77}
78
79/// Worker resource metrics.
80#[derive(Debug, Clone, Default)]
81pub struct WorkerMetrics {
82    /// Total tasks executed.
83    pub tasks_executed: u64,
84    /// Total tasks succeeded.
85    pub tasks_succeeded: u64,
86    /// Total tasks failed.
87    pub tasks_failed: u64,
88    /// Total execution time in milliseconds.
89    pub total_execution_time_ms: u64,
90    /// Current memory usage in bytes.
91    pub memory_usage: u64,
92    /// Number of active tasks.
93    pub active_tasks: u64,
94}
95
96impl WorkerMetrics {
97    /// Record a successful task execution.
98    pub fn record_success(&mut self, execution_time_ms: u64) {
99        self.tasks_executed += 1;
100        self.tasks_succeeded += 1;
101        self.total_execution_time_ms += execution_time_ms;
102    }
103
104    /// Record a failed task execution.
105    pub fn record_failure(&mut self, execution_time_ms: u64) {
106        self.tasks_executed += 1;
107        self.tasks_failed += 1;
108        self.total_execution_time_ms += execution_time_ms;
109    }
110
111    /// Get the success rate.
112    pub fn success_rate(&self) -> f64 {
113        if self.tasks_executed == 0 {
114            0.0
115        } else {
116            self.tasks_succeeded as f64 / self.tasks_executed as f64
117        }
118    }
119
120    /// Get the average execution time.
121    pub fn avg_execution_time_ms(&self) -> f64 {
122        if self.tasks_executed == 0 {
123            0.0
124        } else {
125            self.total_execution_time_ms as f64 / self.tasks_executed as f64
126        }
127    }
128}
129
130/// Worker node for executing distributed tasks.
131pub struct Worker {
132    /// Worker configuration.
133    config: WorkerConfig,
134    /// Current status.
135    status: Arc<RwLock<WorkerStatus>>,
136    /// Worker metrics.
137    metrics: Arc<RwLock<WorkerMetrics>>,
138    /// Currently running tasks.
139    running_tasks: Arc<RwLock<HashMap<TaskId, Instant>>>,
140    /// Shutdown signal.
141    shutdown: Arc<AtomicBool>,
142}
143
144impl Worker {
145    /// Create a new worker.
146    pub fn new(config: WorkerConfig) -> Self {
147        Self {
148            config,
149            status: Arc::new(RwLock::new(WorkerStatus::Idle)),
150            metrics: Arc::new(RwLock::new(WorkerMetrics::default())),
151            running_tasks: Arc::new(RwLock::new(HashMap::new())),
152            shutdown: Arc::new(AtomicBool::new(false)),
153        }
154    }
155
156    /// Get the worker ID.
157    pub fn worker_id(&self) -> &str {
158        &self.config.worker_id
159    }
160
161    /// Get the current status.
162    pub fn status(&self) -> WorkerStatus {
163        self.status.read().map_or(WorkerStatus::Offline, |s| *s)
164    }
165
166    /// Get the current metrics.
167    pub fn metrics(&self) -> WorkerMetrics {
168        self.metrics
169            .read()
170            .map_or_else(|_| WorkerMetrics::default(), |m| m.clone())
171    }
172
173    /// Check if the worker is available for new tasks.
174    pub fn is_available(&self) -> bool {
175        let running_count = self.running_tasks.read().map_or(0, |r| r.len());
176        running_count < self.config.max_concurrent_tasks
177            && self.status() == WorkerStatus::Idle
178            && !self.shutdown.load(Ordering::SeqCst)
179    }
180
181    /// Execute a task.
182    pub async fn execute_task(&self, task: Task, data: Arc<RecordBatch>) -> Result<TaskResult> {
183        // Check if shutdown was requested
184        if self.shutdown.load(Ordering::SeqCst) {
185            return Err(DistributedError::worker_task_failure(
186                "Worker is shutting down",
187            ));
188        }
189
190        // Update status
191        {
192            let mut status = self.status.write().map_err(|_| {
193                DistributedError::worker_task_failure("Failed to acquire status lock")
194            })?;
195            *status = WorkerStatus::Busy;
196        }
197
198        // Record task start
199        {
200            let mut running = self.running_tasks.write().map_err(|_| {
201                DistributedError::worker_task_failure("Failed to acquire running tasks lock")
202            })?;
203            running.insert(task.id, Instant::now());
204        }
205
206        // Create task context
207        let context = TaskContext::new(task.id, self.config.worker_id.clone())
208            .with_memory_limit(self.config.memory_limit)
209            .with_num_cores(self.config.num_cores);
210
211        info!(
212            "Worker {} executing task {:?}",
213            self.config.worker_id, task.id
214        );
215
216        let start = Instant::now();
217
218        // Execute the task operation
219        let result = self
220            .execute_operation(&task.operation, data, &context)
221            .await;
222
223        let execution_time_ms = start.elapsed().as_millis() as u64;
224
225        // Remove from running tasks
226        {
227            let mut running = self.running_tasks.write().map_err(|_| {
228                DistributedError::worker_task_failure("Failed to acquire running tasks lock")
229            })?;
230            running.remove(&task.id);
231        }
232
233        // Update metrics and status
234        {
235            let mut metrics = self.metrics.write().map_err(|_| {
236                DistributedError::worker_task_failure("Failed to acquire metrics lock")
237            })?;
238
239            match &result {
240                Ok(batch) => {
241                    metrics.record_success(execution_time_ms);
242                    info!(
243                        "Worker {} completed task {:?} in {}ms",
244                        self.config.worker_id, task.id, execution_time_ms
245                    );
246
247                    let task_result =
248                        TaskResult::success(task.id, batch.clone(), execution_time_ms);
249
250                    // Update status back to idle if no more tasks
251                    if self.running_tasks.read().map_or(true, |r| r.is_empty())
252                        && let Ok(mut status) = self.status.write()
253                    {
254                        *status = WorkerStatus::Idle;
255                    }
256
257                    Ok(task_result)
258                }
259                Err(e) => {
260                    metrics.record_failure(execution_time_ms);
261                    error!(
262                        "Worker {} failed task {:?}: {}",
263                        self.config.worker_id, task.id, e
264                    );
265
266                    let task_result =
267                        TaskResult::failure(task.id, e.to_string(), execution_time_ms);
268
269                    // Update status back to idle if no more tasks
270                    if self.running_tasks.read().map_or(true, |r| r.is_empty())
271                        && let Ok(mut status) = self.status.write()
272                    {
273                        *status = WorkerStatus::Idle;
274                    }
275
276                    Ok(task_result)
277                }
278            }
279        }
280    }
281
282    /// Execute a specific operation.
283    async fn execute_operation(
284        &self,
285        operation: &TaskOperation,
286        data: Arc<RecordBatch>,
287        _context: &TaskContext,
288    ) -> Result<Arc<RecordBatch>> {
289        match operation {
290            TaskOperation::Filter { expression } => {
291                debug!("Applying filter: {}", expression);
292                let filtered = crate::operations::apply_filter(data.as_ref(), expression)?;
293                Ok(Arc::new(filtered))
294            }
295            TaskOperation::CalculateIndex { index_type, bands } => {
296                debug!("Calculating index: {} with bands {:?}", index_type, bands);
297                let out = crate::operations::calculate_index(data.as_ref(), index_type, bands)?;
298                Ok(Arc::new(out))
299            }
300            TaskOperation::Clip {
301                min_x,
302                min_y,
303                max_x,
304                max_y,
305            } => {
306                debug!(
307                    "Clipping to bbox: [{}, {}, {}, {}]",
308                    min_x, min_y, max_x, max_y
309                );
310                let clipped =
311                    crate::operations::clip_bbox(data.as_ref(), *min_x, *min_y, *max_x, *max_y)?;
312                Ok(Arc::new(clipped))
313            }
314            // The following operations require the raster/CRS engines and are not yet
315            // wired into the distributed worker. They MUST fail loudly rather than
316            // silently return the input unchanged, so the coordinator never mistakes a
317            // skipped operation for a completed one.
318            TaskOperation::Reproject { target_epsg } => {
319                debug!(
320                    "Reproject to EPSG:{} requested (not implemented)",
321                    target_epsg
322                );
323                Err(DistributedError::operation_not_implemented(format!(
324                    "Reproject to EPSG:{} is not yet supported by the distributed worker \
325                     (requires oxigdal-proj integration)",
326                    target_epsg
327                )))
328            }
329            TaskOperation::Resample {
330                width,
331                height,
332                method,
333            } => {
334                debug!(
335                    "Resample to {}x{} ({}) requested (not implemented)",
336                    width, height, method
337                );
338                Err(DistributedError::operation_not_implemented(format!(
339                    "Resample to {}x{} using '{}' is not yet supported by the distributed \
340                     worker (requires oxigdal-raster integration)",
341                    width, height, method
342                )))
343            }
344            TaskOperation::Convolve {
345                kernel: _,
346                kernel_width,
347                kernel_height,
348            } => {
349                debug!(
350                    "Convolve {}x{} requested (not implemented)",
351                    kernel_width, kernel_height
352                );
353                Err(DistributedError::operation_not_implemented(format!(
354                    "Convolution with a {}x{} kernel is not yet supported by the distributed \
355                     worker (requires oxigdal-raster integration)",
356                    kernel_width, kernel_height
357                )))
358            }
359            TaskOperation::Custom { name, params: _ } => {
360                debug!("Custom operation '{}' requested (no handler)", name);
361                Err(DistributedError::operation_not_implemented(format!(
362                    "Custom operation '{}' has no registered handler on this worker",
363                    name
364                )))
365            }
366        }
367    }
368
369    /// Start the worker's heartbeat loop.
370    pub async fn start_heartbeat(&self, heartbeat_tx: mpsc::Sender<String>) -> Result<()> {
371        let worker_id = self.config.worker_id.clone();
372        let interval = self.config.heartbeat_interval_secs;
373        let shutdown = self.shutdown.clone();
374
375        tokio::spawn(async move {
376            let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(interval));
377
378            loop {
379                interval.tick().await;
380
381                if shutdown.load(Ordering::SeqCst) {
382                    debug!("Worker {} heartbeat loop shutting down", worker_id);
383                    break;
384                }
385
386                if let Err(e) = heartbeat_tx.send(worker_id.clone()).await {
387                    warn!("Failed to send heartbeat for worker {}: {}", worker_id, e);
388                    break;
389                }
390
391                debug!("Worker {} sent heartbeat", worker_id);
392            }
393        });
394
395        Ok(())
396    }
397
398    /// Initiate graceful shutdown.
399    pub async fn shutdown(&self) -> Result<()> {
400        info!("Worker {} initiating shutdown", self.config.worker_id);
401
402        self.shutdown.store(true, Ordering::SeqCst);
403
404        // Update status
405        {
406            let mut status = self.status.write().map_err(|_| {
407                DistributedError::worker_task_failure("Failed to acquire status lock")
408            })?;
409            *status = WorkerStatus::ShuttingDown;
410        }
411
412        // Wait for running tasks to complete (with timeout)
413        let timeout = tokio::time::Duration::from_secs(30);
414        let start = Instant::now();
415
416        while start.elapsed() < timeout {
417            let running_count = self.running_tasks.read().map_or(0, |r| r.len());
418            if running_count == 0 {
419                break;
420            }
421            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
422        }
423
424        // Final status update
425        {
426            let mut status = self.status.write().map_err(|_| {
427                DistributedError::worker_task_failure("Failed to acquire status lock")
428            })?;
429            *status = WorkerStatus::Offline;
430        }
431
432        info!("Worker {} shutdown complete", self.config.worker_id);
433        Ok(())
434    }
435
436    /// Get health check information.
437    pub fn health_check(&self) -> WorkerHealthCheck {
438        let metrics = self.metrics();
439        let status = self.status();
440        let running_count = self.running_tasks.read().map_or(0, |r| r.len());
441
442        WorkerHealthCheck {
443            worker_id: self.config.worker_id.clone(),
444            status,
445            is_healthy: status != WorkerStatus::Offline,
446            active_tasks: running_count,
447            total_tasks_executed: metrics.tasks_executed,
448            success_rate: metrics.success_rate(),
449            avg_execution_time_ms: metrics.avg_execution_time_ms(),
450            memory_usage: metrics.memory_usage,
451        }
452    }
453}
454
455/// Health check information for a worker.
456#[derive(Debug, Clone)]
457pub struct WorkerHealthCheck {
458    /// Worker identifier.
459    pub worker_id: String,
460    /// Current status.
461    pub status: WorkerStatus,
462    /// Whether the worker is healthy.
463    pub is_healthy: bool,
464    /// Number of active tasks.
465    pub active_tasks: usize,
466    /// Total tasks executed.
467    pub total_tasks_executed: u64,
468    /// Success rate (0.0 to 1.0).
469    pub success_rate: f64,
470    /// Average execution time in milliseconds.
471    pub avg_execution_time_ms: f64,
472    /// Current memory usage in bytes.
473    pub memory_usage: u64,
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::task::PartitionId;
480    use arrow::array::Int32Array;
481    use arrow::datatypes::{DataType, Field, Schema};
482
483    fn create_test_batch() -> std::result::Result<Arc<RecordBatch>, Box<dyn std::error::Error>> {
484        let schema = Arc::new(Schema::new(vec![Field::new(
485            "value",
486            DataType::Int32,
487            false,
488        )]));
489
490        let array = Int32Array::from(vec![1, 2, 3, 4, 5]);
491
492        Ok(Arc::new(RecordBatch::try_new(
493            schema,
494            vec![Arc::new(array)],
495        )?))
496    }
497
498    #[test]
499    fn test_worker_config() {
500        let config = WorkerConfig::new("worker-1".to_string())
501            .with_max_concurrent_tasks(8)
502            .with_memory_limit(8 * 1024 * 1024 * 1024);
503
504        assert_eq!(config.worker_id, "worker-1");
505        assert_eq!(config.max_concurrent_tasks, 8);
506        assert_eq!(config.memory_limit, 8 * 1024 * 1024 * 1024);
507    }
508
509    #[test]
510    fn test_worker_metrics() {
511        let mut metrics = WorkerMetrics::default();
512
513        metrics.record_success(100);
514        metrics.record_success(200);
515        metrics.record_failure(150);
516
517        assert_eq!(metrics.tasks_executed, 3);
518        assert_eq!(metrics.tasks_succeeded, 2);
519        assert_eq!(metrics.tasks_failed, 1);
520        assert_eq!(metrics.total_execution_time_ms, 450);
521        assert_eq!(metrics.success_rate(), 2.0 / 3.0);
522        assert_eq!(metrics.avg_execution_time_ms(), 150.0);
523    }
524
525    #[tokio::test]
526    async fn test_worker_creation() {
527        let config = WorkerConfig::new("worker-test".to_string());
528        let worker = Worker::new(config);
529
530        assert_eq!(worker.worker_id(), "worker-test");
531        assert_eq!(worker.status(), WorkerStatus::Idle);
532        assert!(worker.is_available());
533    }
534
535    #[tokio::test]
536    async fn test_worker_execute_task() -> std::result::Result<(), Box<dyn std::error::Error>> {
537        let config = WorkerConfig::new("worker-test".to_string());
538        let worker = Worker::new(config);
539
540        let task = Task::new(
541            TaskId(1),
542            PartitionId(0),
543            TaskOperation::Filter {
544                expression: "value > 2".to_string(),
545            },
546        );
547
548        let data = create_test_batch()?;
549        let result = worker.execute_task(task, data).await;
550
551        assert!(result.is_ok());
552        let task_result = result?;
553        assert!(task_result.is_success());
554        // The filter must actually run: 5 input rows (1..=5), 3 survive `value > 2`.
555        let out = task_result
556            .data
557            .ok_or_else(|| Box::<dyn std::error::Error>::from("success result must carry data"))?;
558        assert_eq!(out.num_rows(), 3);
559        Ok(())
560    }
561
562    #[tokio::test]
563    async fn test_worker_filter_bad_expression_fails()
564    -> std::result::Result<(), Box<dyn std::error::Error>> {
565        let worker = Worker::new(WorkerConfig::new("worker-test".to_string()));
566        let task = Task::new(
567            TaskId(2),
568            PartitionId(0),
569            TaskOperation::Filter {
570                expression: "nonexistent_column > 2".to_string(),
571            },
572        );
573        let result = worker.execute_task(task, create_test_batch()?).await?;
574        // Unknown column must surface as a task failure, not a silent success.
575        assert!(result.is_failure());
576        Ok(())
577    }
578
579    #[tokio::test]
580    async fn test_worker_calculate_index_adds_column()
581    -> std::result::Result<(), Box<dyn std::error::Error>> {
582        use arrow::array::Float64Array;
583        let schema = Arc::new(Schema::new(vec![
584            Field::new("red", DataType::Float64, false),
585            Field::new("nir", DataType::Float64, false),
586        ]));
587        let batch = Arc::new(RecordBatch::try_new(
588            schema,
589            vec![
590                Arc::new(Float64Array::from(vec![1.0, 2.0])),
591                Arc::new(Float64Array::from(vec![3.0, 6.0])),
592            ],
593        )?);
594
595        let worker = Worker::new(WorkerConfig::new("worker-test".to_string()));
596        let task = Task::new(
597            TaskId(3),
598            PartitionId(0),
599            TaskOperation::CalculateIndex {
600                index_type: "NDVI".to_string(),
601                bands: vec![0, 1],
602            },
603        );
604        let result = worker.execute_task(task, batch).await?;
605        assert!(result.is_success());
606        let out = result
607            .data
608            .ok_or_else(|| Box::<dyn std::error::Error>::from("must carry data"))?;
609        assert_eq!(out.num_columns(), 3);
610        assert_eq!(out.schema().field(2).name(), "NDVI");
611        Ok(())
612    }
613
614    #[tokio::test]
615    async fn test_worker_unimplemented_ops_fail_loudly()
616    -> std::result::Result<(), Box<dyn std::error::Error>> {
617        let worker = Worker::new(WorkerConfig::new("worker-test".to_string()));
618
619        let ops = vec![
620            TaskOperation::Reproject { target_epsg: 4326 },
621            TaskOperation::Resample {
622                width: 10,
623                height: 10,
624                method: "bilinear".to_string(),
625            },
626            TaskOperation::Convolve {
627                kernel: vec![1.0; 9],
628                kernel_width: 3,
629                kernel_height: 3,
630            },
631            TaskOperation::Custom {
632                name: "mystery".to_string(),
633                params: "{}".to_string(),
634            },
635        ];
636
637        for (i, op) in ops.into_iter().enumerate() {
638            let task = Task::new(TaskId(100 + i as u64), PartitionId(0), op);
639            let result = worker.execute_task(task, create_test_batch()?).await?;
640            // These operations are not implemented and MUST report failure rather than
641            // returning the unmodified input as if they had succeeded.
642            assert!(result.is_failure(), "operation {} should have failed", i);
643        }
644        Ok(())
645    }
646
647    #[tokio::test]
648    async fn test_worker_health_check() {
649        let config = WorkerConfig::new("worker-test".to_string());
650        let worker = Worker::new(config);
651
652        let health = worker.health_check();
653
654        assert_eq!(health.worker_id, "worker-test");
655        assert!(health.is_healthy);
656        assert_eq!(health.active_tasks, 0);
657        assert_eq!(health.total_tasks_executed, 0);
658    }
659}