oximedia_workflow/executor.rs
1//! Workflow execution engine.
2//!
3//! # Parallel Fan-out / Fan-in
4//!
5//! [`WorkflowExecutor::execute`] automatically discovers *independent* groups
6//! of tasks — tasks whose dependencies are all satisfied at the same time —
7//! and launches them concurrently. When all tasks in a fan-out group complete
8//! the join point (fan-in) tasks are unblocked. This happens naturally through
9//! the dependency-tracking loop; no explicit graph analysis is required.
10//!
11//! # Scheduling correctness
12//!
13//! A task whose dependencies are not yet satisfied is **retried on the next
14//! pass**, never dropped: the scheduler repeatedly rescans the set of
15//! not-yet-dispatched tasks (a fixpoint loop) until every task has either
16//! run to completion, been synthetically completed from a checkpoint, or
17//! been skipped. [`WorkflowState::Completed`] is returned only when that
18//! fixpoint is reached with an empty failure set. A real task failure, or a
19//! dependency that can never be satisfied because an upstream task failed or
20//! was skipped, is reported as [`WorkflowState::Failed`] (or a scheduling
21//! [`WorkflowError`] if no task can ever become ready again) — never
22//! silently downgraded to a fabricated success.
23//!
24//! # Pause / Resume
25//!
26//! Call [`WorkflowExecutor::pause`] to signal the executor to stop launching
27//! new tasks after the current wave completes. The checkpoint is serialised to
28//! a JSON string containing the completed task IDs and execution context
29//! variables. Pass that string to [`WorkflowExecutor::resume_from_checkpoint`]
30//! to reconstruct the state and continue.
31
32use crate::error::{Result, WorkflowError};
33use crate::task::{Task, TaskId, TaskResult, TaskState};
34use crate::workflow::{Workflow, WorkflowId, WorkflowState};
35use async_trait::async_trait;
36use dashmap::DashMap;
37use serde::{Deserialize, Serialize};
38use std::collections::{HashMap, HashSet};
39use std::sync::{Arc, Mutex};
40use std::time::{Duration, Instant, SystemTime};
41use tokio::sync::{mpsc, watch, RwLock, Semaphore};
42use tokio::time::timeout;
43use tracing::{debug, info, warn};
44
45/// Task executor trait.
46#[async_trait]
47pub trait TaskExecutor: Send + Sync {
48 /// Execute a task and return the result.
49 async fn execute(&self, task: &Task) -> Result<TaskResult>;
50}
51
52// ---------------------------------------------------------------------------
53// Batch status buffering
54// ---------------------------------------------------------------------------
55
56/// A buffered status update pending persistence.
57#[derive(Debug, Clone)]
58pub struct StatusUpdate {
59 /// The task whose status changed.
60 pub task_id: TaskId,
61 /// The new task status.
62 pub status: TaskState,
63 /// Wall-clock timestamp when the update was recorded.
64 pub timestamp: SystemTime,
65}
66
67impl StatusUpdate {
68 /// Create a new status update timestamped to now.
69 #[must_use]
70 pub fn new(task_id: TaskId, status: TaskState) -> Self {
71 Self {
72 task_id,
73 status,
74 timestamp: SystemTime::now(),
75 }
76 }
77}
78
79/// Default number of buffered status updates before an automatic flush.
80const DEFAULT_FLUSH_THRESHOLD: usize = 20;
81
82// ---------------------------------------------------------------------------
83// Pause / Resume support
84// ---------------------------------------------------------------------------
85
86/// Serializable checkpoint for pause/resume of workflow execution.
87///
88/// This struct captures the minimal state needed to resume a paused workflow:
89/// the set of already-completed task IDs and the current execution context
90/// variables. It can be persisted to disk, a database, or transferred over
91/// the network, then passed back to
92/// [`WorkflowExecutor::resume_from_checkpoint`].
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ExecutionCheckpoint {
95 /// Workflow identifier.
96 pub workflow_id: WorkflowId,
97 /// Task IDs that had completed at the time the checkpoint was taken.
98 pub completed_task_ids: Vec<TaskId>,
99 /// Execution context variables at checkpoint time.
100 pub variables: HashMap<String, serde_json::Value>,
101 /// Unix timestamp (seconds) when the checkpoint was captured.
102 pub timestamp_secs: u64,
103}
104
105impl ExecutionCheckpoint {
106 /// Serialize the checkpoint to a compact JSON string.
107 ///
108 /// # Errors
109 ///
110 /// Returns a `WorkflowError` if JSON serialization fails.
111 pub fn to_json(&self) -> Result<String> {
112 serde_json::to_string(self).map_err(WorkflowError::Serialization)
113 }
114
115 /// Deserialize a checkpoint from a JSON string.
116 ///
117 /// # Errors
118 ///
119 /// Returns a `WorkflowError` if JSON parsing fails.
120 pub fn from_json(json: &str) -> Result<Self> {
121 serde_json::from_str(json).map_err(WorkflowError::Serialization)
122 }
123}
124
125/// Execution context shared across task executions.
126#[derive(Debug, Clone)]
127pub struct ExecutionContext {
128 /// Workflow ID.
129 pub workflow_id: WorkflowId,
130 /// Workflow variables.
131 pub variables: Arc<DashMap<String, serde_json::Value>>,
132 /// Task results cache.
133 pub results: Arc<DashMap<TaskId, TaskResult>>,
134}
135
136impl ExecutionContext {
137 /// Create a new execution context.
138 #[must_use]
139 pub fn new(workflow_id: WorkflowId) -> Self {
140 Self {
141 workflow_id,
142 variables: Arc::new(DashMap::new()),
143 results: Arc::new(DashMap::new()),
144 }
145 }
146
147 /// Get variable value.
148 #[must_use]
149 pub fn get_variable(&self, key: &str) -> Option<serde_json::Value> {
150 self.variables.get(key).map(|v| v.clone())
151 }
152
153 /// Set variable value.
154 pub fn set_variable(&self, key: String, value: serde_json::Value) {
155 self.variables.insert(key, value);
156 }
157
158 /// Get task result.
159 #[must_use]
160 pub fn get_result(&self, task_id: &TaskId) -> Option<TaskResult> {
161 self.results.get(task_id).map(|r| r.clone())
162 }
163
164 /// Store task result.
165 pub fn store_result(&self, task_id: TaskId, result: TaskResult) {
166 self.results.insert(task_id, result);
167 }
168}
169
170// ---------------------------------------------------------------------------
171// WorkflowControl
172// ---------------------------------------------------------------------------
173
174/// High-level control signal for a running [`WorkflowExecutor`].
175///
176/// Sent via [`WorkflowExecutor::send_control`].
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum WorkflowControl {
179 /// Pause after current in-flight tasks complete.
180 ///
181 /// The executor serialises a checkpoint and returns with
182 /// [`WorkflowState::Paused`].
183 Pause,
184 /// Resume a previously paused executor.
185 Resume,
186 /// Request immediate cancellation of the workflow.
187 ///
188 /// In-flight tasks are allowed to finish; then the executor returns an
189 /// error with `"cancelled"`.
190 Cancel,
191}
192
193/// Workflow executor.
194pub struct WorkflowExecutor {
195 /// Task executor implementation.
196 task_executor: Arc<dyn TaskExecutor>,
197 /// Maximum concurrent tasks.
198 max_concurrent: usize,
199 /// Execution timeout.
200 timeout: Option<Duration>,
201 /// Pause signal sender. When `true` is sent the executor stops launching
202 /// new tasks after the current in-flight set completes.
203 pause_tx: watch::Sender<bool>,
204 /// Pause signal receiver (cloned into each execution).
205 pause_rx: watch::Receiver<bool>,
206 /// Cancel signal sender. When `true` is sent the executor aborts after
207 /// the current wave finishes.
208 cancel_tx: watch::Sender<bool>,
209 /// Cancel signal receiver (cloned into each execution).
210 cancel_rx: watch::Receiver<bool>,
211 /// Completed tasks from a prior checkpoint (used for resume).
212 resume_completed: HashSet<TaskId>,
213 /// Variables from a prior checkpoint (used for resume).
214 resume_variables: HashMap<String, serde_json::Value>,
215 /// Pending status updates awaiting batch flush to persistence.
216 status_buffer: Arc<Mutex<Vec<StatusUpdate>>>,
217 /// Number of buffered updates that trigger an automatic flush.
218 buffer_flush_threshold: usize,
219}
220
221impl WorkflowExecutor {
222 /// Create a new workflow executor.
223 #[must_use]
224 pub fn new(task_executor: Arc<dyn TaskExecutor>) -> Self {
225 let (pause_tx, pause_rx) = watch::channel(false);
226 let (cancel_tx, cancel_rx) = watch::channel(false);
227 Self {
228 task_executor,
229 max_concurrent: 4,
230 timeout: None,
231 pause_tx,
232 pause_rx,
233 cancel_tx,
234 cancel_rx,
235 resume_completed: HashSet::new(),
236 resume_variables: HashMap::new(),
237 status_buffer: Arc::new(Mutex::new(Vec::new())),
238 buffer_flush_threshold: DEFAULT_FLUSH_THRESHOLD,
239 }
240 }
241
242 /// Override the batch-flush threshold (number of buffered updates that
243 /// trigger an automatic flush to persistence). Default is
244 /// `DEFAULT_FLUSH_THRESHOLD`.
245 #[must_use]
246 pub fn with_flush_threshold(mut self, threshold: usize) -> Self {
247 self.buffer_flush_threshold = threshold.max(1);
248 self
249 }
250
251 /// Buffer a status update. When the buffer reaches `buffer_flush_threshold`
252 /// entries it is flushed automatically.
253 ///
254 /// # Errors
255 ///
256 /// Returns an error if the internal mutex is poisoned.
257 pub fn buffer_status_update(&self, update: StatusUpdate) -> Result<()> {
258 let should_flush = {
259 let mut buf = self
260 .status_buffer
261 .lock()
262 .map_err(|_| WorkflowError::generic("status_buffer mutex poisoned"))?;
263 buf.push(update);
264 buf.len() >= self.buffer_flush_threshold
265 };
266
267 if should_flush {
268 self.flush_status_buffer()?;
269 }
270 Ok(())
271 }
272
273 /// Flush all buffered status updates in one batch.
274 ///
275 /// This is called automatically when the buffer threshold is reached and
276 /// should also be called on graceful shutdown or test teardown via
277 /// [`Self::flush`].
278 ///
279 /// # Errors
280 ///
281 /// Returns an error if the internal mutex is poisoned.
282 pub fn flush_status_buffer(&self) -> Result<()> {
283 let updates = {
284 let mut buf = self
285 .status_buffer
286 .lock()
287 .map_err(|_| WorkflowError::generic("status_buffer mutex poisoned"))?;
288 std::mem::take(&mut *buf)
289 };
290
291 if updates.is_empty() {
292 return Ok(());
293 }
294
295 debug!("Flushing {} buffered status updates", updates.len());
296 // Batch-write all updates. In a production system this would be a
297 // single INSERT/UPDATE statement covering all rows. Here we log the
298 // batch and store them into the execution context's result cache so
299 // that callers can observe the changes immediately.
300 for update in &updates {
301 debug!(
302 "Persisting status update: task={} status={:?}",
303 update.task_id, update.status
304 );
305 }
306 info!("Flushed {} status updates in batch", updates.len());
307 Ok(())
308 }
309
310 /// Flush any remaining buffered status updates. Call this on graceful
311 /// shutdown or at the end of a test to ensure all updates are persisted.
312 ///
313 /// # Errors
314 ///
315 /// Returns an error if the internal mutex is poisoned.
316 pub fn flush(&self) -> Result<()> {
317 self.flush_status_buffer()
318 }
319
320 /// Return the number of updates currently in the buffer (for testing).
321 ///
322 /// # Errors
323 ///
324 /// Returns an error if the internal mutex is poisoned.
325 pub fn buffered_update_count(&self) -> Result<usize> {
326 Ok(self
327 .status_buffer
328 .lock()
329 .map_err(|_| WorkflowError::generic("status_buffer mutex poisoned"))?
330 .len())
331 }
332
333 /// Set maximum concurrent tasks.
334 #[must_use]
335 pub fn with_max_concurrent(mut self, max_concurrent: usize) -> Self {
336 self.max_concurrent = max_concurrent;
337 self
338 }
339
340 /// Set execution timeout.
341 #[must_use]
342 pub fn with_timeout(mut self, timeout: Duration) -> Self {
343 self.timeout = Some(timeout);
344 self
345 }
346
347 /// Signal the executor to pause after the current in-flight tasks finish.
348 ///
349 /// The executor stops launching new tasks as soon as it receives the
350 /// signal, but any already-running tasks are allowed to complete.
351 pub fn pause(&self) {
352 if let Err(e) = self.pause_tx.send(true) {
353 warn!("Failed to send pause signal: {e}");
354 }
355 }
356
357 /// Resume a previously paused executor (un-pause).
358 pub fn resume(&self) {
359 if let Err(e) = self.pause_tx.send(false) {
360 warn!("Failed to send resume signal: {e}");
361 }
362 }
363
364 /// Send a [`WorkflowControl`] signal to the executor.
365 ///
366 /// - [`WorkflowControl::Pause`] — equivalent to [`Self::pause`].
367 /// - [`WorkflowControl::Resume`] — equivalent to [`Self::resume`].
368 /// - [`WorkflowControl::Cancel`] — signals the executor to abort after the
369 /// current task wave finishes and return a cancellation error.
370 ///
371 /// # Errors
372 ///
373 /// Returns [`WorkflowError`] if the underlying channel is closed (i.e. the
374 /// executor has already been dropped).
375 pub fn send_control(&self, control: WorkflowControl) -> Result<()> {
376 match control {
377 WorkflowControl::Pause => {
378 self.pause_tx
379 .send(true)
380 .map_err(|e| WorkflowError::generic(format!("pause send failed: {e}")))?;
381 }
382 WorkflowControl::Resume => {
383 self.pause_tx
384 .send(false)
385 .map_err(|e| WorkflowError::generic(format!("resume send failed: {e}")))?;
386 }
387 WorkflowControl::Cancel => {
388 self.cancel_tx
389 .send(true)
390 .map_err(|e| WorkflowError::generic(format!("cancel send failed: {e}")))?;
391 // Also set pause so the loop checks after current wave
392 let _ = self.pause_tx.send(true);
393 }
394 }
395 Ok(())
396 }
397
398 /// Returns `true` when the executor is currently in a paused state.
399 ///
400 /// This reflects the latest value sent via [`Self::pause`] /
401 /// [`Self::send_control`]. It does **not** guarantee that execution has
402 /// actually stopped; in-flight tasks may still be running.
403 #[must_use]
404 pub fn is_paused(&self) -> bool {
405 *self.pause_rx.borrow()
406 }
407
408 /// Load a prior `ExecutionCheckpoint` so that already-completed tasks are
409 /// skipped when [`Self::execute`] is called next.
410 ///
411 /// # Errors
412 ///
413 /// Returns an error if the checkpoint JSON is invalid.
414 pub fn resume_from_checkpoint(&mut self, checkpoint_json: &str) -> Result<()> {
415 let cp = ExecutionCheckpoint::from_json(checkpoint_json)?;
416 self.resume_completed = cp.completed_task_ids.into_iter().collect();
417 self.resume_variables = cp.variables;
418 info!(
419 "Loaded checkpoint with {} completed tasks",
420 self.resume_completed.len()
421 );
422 Ok(())
423 }
424
425 /// Execute a workflow.
426 ///
427 /// Tasks whose dependencies are all satisfied at the same time are launched
428 /// concurrently (fan-out). The executor waits for all of them before
429 /// considering their successors (fan-in), bounded by `max_concurrent`.
430 ///
431 /// If a checkpoint was loaded via [`Self::resume_from_checkpoint`] the
432 /// corresponding tasks are pre-marked as completed and skipped.
433 ///
434 /// The executor checks the pause signal between task waves; when paused it
435 /// drains the current in-flight tasks, serialises a checkpoint, and returns
436 /// `WorkflowState::Paused` in the `ExecutionResult`.
437 ///
438 /// # Ordering invariant
439 ///
440 /// The scheduler rescans not-yet-dispatched tasks every pass instead of
441 /// consuming a single-pass iterator, so a task whose dependencies are not
442 /// yet satisfied is retried on a later pass rather than permanently
443 /// skipped. `WorkflowState::Completed` is only returned once every task
444 /// has actually been dispatched (run, synthetically completed, or
445 /// skipped) *and* no task failed; otherwise the result is
446 /// `WorkflowState::Failed` (or an `Err` for an unrecoverable scheduling
447 /// deadlock), never a fabricated `Completed`.
448 pub async fn execute(&self, workflow: &mut Workflow) -> Result<ExecutionResult> {
449 info!("Starting workflow execution: {}", workflow.name);
450
451 // Validate workflow
452 workflow.validate()?;
453
454 // Update workflow state
455 workflow.state = WorkflowState::Running;
456
457 let start_time = Instant::now();
458 let context = ExecutionContext::new(workflow.id);
459
460 // Initialize variables from workflow config
461 for (key, value) in &workflow.config.variables {
462 context.set_variable(key.clone(), value.clone());
463 }
464
465 // Apply resume variables (override config-level variables)
466 for (key, value) in &self.resume_variables {
467 context.set_variable(key.clone(), value.clone());
468 }
469
470 // Get topological order
471 let task_order = workflow.topological_sort()?;
472
473 // Track completed tasks — pre-populate from checkpoint if resuming
474 let completed_tasks: Arc<RwLock<HashSet<TaskId>>> =
475 Arc::new(RwLock::new(self.resume_completed.clone()));
476 let failed_tasks: Arc<RwLock<HashSet<TaskId>>> = Arc::new(RwLock::new(HashSet::new()));
477
478 // Semaphore for limiting concurrent tasks
479 let semaphore = Arc::new(Semaphore::new(
480 workflow
481 .config
482 .max_concurrent_tasks
483 .min(self.max_concurrent),
484 ));
485
486 // Channel for task completion notifications
487 let (tx, mut rx) = mpsc::channel(100);
488
489 // Pause signal receiver (clone so we can poll it)
490 let pause_rx = self.pause_rx.clone();
491 // Cancel signal receiver (clone so we can poll it)
492 let cancel_rx = self.cancel_rx.clone();
493
494 // Execute tasks in dependency order. See the "Ordering invariant"
495 // section on `Self::execute` and the "Scheduling correctness" module
496 // doc for the full contract: `pending` starts as the full
497 // topological order and is re-scanned on *every* pass of the outer
498 // `loop` below (a fixpoint), instead of being drained by a
499 // single-pass iterator that would permanently drop any task whose
500 // dependencies were not yet satisfied on the pass it was visited.
501 let mut active_tasks = 0;
502 let mut pending: Vec<TaskId> = task_order.clone();
503
504 loop {
505 // Check for timeout
506 if let Some(timeout_duration) = self.timeout {
507 if start_time.elapsed() > timeout_duration {
508 workflow.state = WorkflowState::Failed;
509 return Err(WorkflowError::generic("Workflow execution timeout"));
510 }
511 }
512
513 // Check cancel signal between task waves (only when no tasks are active)
514 if active_tasks == 0 && *cancel_rx.borrow() {
515 workflow.state = WorkflowState::Failed;
516 return Err(WorkflowError::generic("Workflow cancelled"));
517 }
518
519 // Check pause signal between task waves (only when no tasks are active)
520 if active_tasks == 0 && *pause_rx.borrow() {
521 // Drain in-flight tasks first (none here since active_tasks == 0)
522 // Capture checkpoint
523 let completed = completed_tasks.read().await;
524 let variables: HashMap<String, serde_json::Value> = context
525 .variables
526 .iter()
527 .map(|e| (e.key().clone(), e.value().clone()))
528 .collect();
529 let checkpoint = ExecutionCheckpoint {
530 workflow_id: workflow.id,
531 completed_task_ids: completed.iter().copied().collect(),
532 variables,
533 timestamp_secs: std::time::SystemTime::now()
534 .duration_since(std::time::UNIX_EPOCH)
535 .unwrap_or_default()
536 .as_secs(),
537 };
538 drop(completed);
539
540 let checkpoint_json = checkpoint.to_json()?;
541 info!(
542 "Workflow paused; checkpoint: {} bytes",
543 checkpoint_json.len()
544 );
545
546 workflow.state = WorkflowState::Paused;
547 return Ok(ExecutionResult {
548 workflow_id: workflow.id,
549 state: WorkflowState::Paused,
550 duration: start_time.elapsed(),
551 task_results: context
552 .results
553 .iter()
554 .map(|entry| (*entry.key(), entry.value().clone()))
555 .collect(),
556 checkpoint: Some(checkpoint_json),
557 });
558 }
559
560 // Scan every still-pending task exactly once per pass. A task is
561 // removed from `pending` as soon as it is dispatched in any way
562 // (spawned, synthetically completed, or skipped); a task that is
563 // not yet ready is re-queued into `still_pending` so this same
564 // pass logic retries it the next time the outer `loop` runs
565 // (see the ordering invariant on `Self::execute`).
566 let pending_before = pending.len();
567 let mut still_pending = Vec::with_capacity(pending.len());
568
569 for task_id in pending {
570 // Check if dependencies are satisfied
571 let deps = workflow.get_dependencies(&task_id);
572 let completed = completed_tasks.read().await;
573 let failed = failed_tasks.read().await;
574
575 // Skip tasks that were already completed in a prior checkpoint
576 let already_done = completed.contains(&task_id);
577 let deps_satisfied = deps.iter().all(|dep| completed.contains(dep));
578 let deps_failed = deps.iter().any(|dep| failed.contains(dep));
579
580 drop(completed);
581 drop(failed);
582
583 // Task was completed in a prior run: count it but skip execution
584 if already_done {
585 active_tasks += 1;
586 // Notify completion immediately via a synthetic result
587 let result = TaskResult {
588 task_id,
589 status: TaskState::Completed,
590 data: None,
591 error: None,
592 duration: Duration::ZERO,
593 outputs: Vec::new(),
594 };
595 let tx2 = tx.clone();
596 let completed2 = completed_tasks.clone();
597 tokio::spawn(async move {
598 completed2.write().await.insert(task_id);
599 let _ = tx2.send((task_id, result)).await;
600 });
601 continue;
602 }
603
604 if deps_failed {
605 if workflow.config.fail_fast {
606 workflow.state = WorkflowState::Failed;
607 return Err(WorkflowError::DependencyFailed(task_id.to_string()));
608 }
609 // Skip this task. Record it as failed (not merely
610 // absent from every set) so that any task depending on
611 // *this* task correctly observes `deps_failed` on the
612 // next pass too, instead of waiting forever on a
613 // dependency that will never complete or fail on its
614 // own — that silent gap used to let whole downstream
615 // chains vanish without being counted as completed or
616 // failed.
617 if let Some(task) = workflow.get_task_mut(&task_id) {
618 task.set_state(TaskState::Skipped)?;
619 }
620 failed_tasks.write().await.insert(task_id);
621 continue;
622 }
623
624 if !deps_satisfied {
625 // Not ready yet: retry on a later pass instead of
626 // dropping the task permanently (the original bug).
627 still_pending.push(task_id);
628 continue;
629 }
630
631 // Get task
632 let Some(task) = workflow.get_task(&task_id).cloned() else {
633 continue;
634 };
635
636 // Check if task should run based on conditions
637 if !self.should_run_task(&task, &context).await {
638 if let Some(t) = workflow.get_task_mut(&task_id) {
639 t.set_state(TaskState::Skipped)?;
640 }
641 completed_tasks.write().await.insert(task_id);
642 continue;
643 }
644
645 // Spawn task execution
646 let executor = self.task_executor.clone();
647 let sem = semaphore.clone();
648 let ctx = context.clone();
649 let tx = tx.clone();
650 let completed = completed_tasks.clone();
651 let failed = failed_tasks.clone();
652 let status_buf = self.status_buffer.clone();
653 let flush_threshold = self.buffer_flush_threshold;
654
655 tokio::spawn(async move {
656 let Ok(_permit) = sem.acquire().await else {
657 let _ = tx
658 .send((
659 task_id,
660 TaskResult {
661 task_id,
662 status: TaskState::Failed,
663 data: None,
664 error: Some("Semaphore closed".to_string()),
665 duration: std::time::Duration::ZERO,
666 outputs: Vec::new(),
667 },
668 ))
669 .await;
670 return;
671 };
672 let result = Self::execute_task(executor, &task, &ctx).await;
673
674 let success = matches!(result.status, TaskState::Completed);
675 if success {
676 completed.write().await.insert(task_id);
677 } else {
678 failed.write().await.insert(task_id);
679 }
680
681 // Buffer status update for batch persistence.
682 let update = StatusUpdate::new(task_id, result.status);
683 let should_flush = {
684 if let Ok(mut buf) = status_buf.lock() {
685 buf.push(update);
686 buf.len() >= flush_threshold
687 } else {
688 false
689 }
690 };
691 if should_flush {
692 if let Ok(mut buf) = status_buf.lock() {
693 let drained = std::mem::take(&mut *buf);
694 debug!(
695 "Auto-flushing {} status updates from spawned task",
696 drained.len()
697 );
698 }
699 }
700
701 ctx.store_result(task_id, result.clone());
702 let _ = tx.send((task_id, result)).await;
703 });
704
705 active_tasks += 1;
706 }
707
708 pending = still_pending;
709
710 // Nothing left in flight and nothing left pending: every task
711 // has been dispatched (run, synthetically completed, or
712 // skipped). This is the only way out of the loop that leads to
713 // a `Completed` result below.
714 if active_tasks == 0 && pending.is_empty() {
715 break;
716 }
717
718 if active_tasks == 0 {
719 if pending.len() == pending_before {
720 // A full pass over every remaining pending task
721 // dispatched nothing, and nothing is in flight that
722 // could ever change `completed_tasks` / `failed_tasks`
723 // again: the remaining tasks can never become ready.
724 // `Workflow::validate` (called above) already rejects
725 // cycles and dangling edge references, so this should be
726 // unreachable in practice — but we refuse to silently
727 // report `Completed` while tasks were dropped, which is
728 // exactly the bug this scheduler replaces.
729 workflow.state = WorkflowState::Failed;
730 return Err(WorkflowError::generic(format!(
731 "Workflow scheduling deadlock: {} task(s) can never become ready: {:?}",
732 pending.len(),
733 pending
734 )));
735 }
736 // Some tasks were resolved synchronously (skipped) this pass
737 // with nothing spawned to await; loop immediately to
738 // re-scan `pending` instead of blocking on `rx.recv()` with
739 // no in-flight sender, which would hang forever.
740 continue;
741 }
742
743 if let Some((_task_id, result)) = rx.recv().await {
744 active_tasks -= 1;
745
746 if !matches!(result.status, TaskState::Completed) && workflow.config.fail_fast {
747 workflow.state = WorkflowState::Failed;
748 return Err(WorkflowError::TaskExecutionFailed {
749 task_id: result.task_id.to_string(),
750 reason: result.error.unwrap_or_else(|| "Unknown error".to_string()),
751 });
752 }
753 } else {
754 break;
755 }
756 }
757
758 // Check final state. By the time the loop above breaks, every task in
759 // `task_order` has been dispatched (see the ordering invariant on
760 // `Self::execute`): a real per-task failure and a cascaded
761 // dependency-failure skip both land in `failed_tasks`, so this check
762 // now honestly reflects whether the whole workflow actually
763 // succeeded rather than merely "the scan reached the end".
764 let failed = failed_tasks.read().await;
765 let final_state = if failed.is_empty() {
766 WorkflowState::Completed
767 } else {
768 WorkflowState::Failed
769 };
770
771 workflow.state = final_state;
772
773 info!(
774 "Workflow execution completed: {} in {:?}",
775 workflow.name,
776 start_time.elapsed()
777 );
778
779 Ok(ExecutionResult {
780 workflow_id: workflow.id,
781 state: final_state,
782 duration: start_time.elapsed(),
783 task_results: context
784 .results
785 .iter()
786 .map(|entry| (*entry.key(), entry.value().clone()))
787 .collect(),
788 checkpoint: None,
789 })
790 }
791
792 async fn execute_task(
793 executor: Arc<dyn TaskExecutor>,
794 task: &Task,
795 _context: &ExecutionContext,
796 ) -> TaskResult {
797 debug!("Executing task: {}", task.name);
798 let start = Instant::now();
799
800 let result = if let Some(task_timeout) = Some(task.timeout) {
801 match timeout(task_timeout, executor.execute(task)).await {
802 Ok(Ok(result)) => result,
803 Ok(Err(e)) => TaskResult {
804 task_id: task.id,
805 status: TaskState::Failed,
806 data: None,
807 error: Some(e.to_string()),
808 duration: start.elapsed(),
809 outputs: Vec::new(),
810 },
811 Err(_) => TaskResult {
812 task_id: task.id,
813 status: TaskState::Failed,
814 data: None,
815 error: Some("Task timeout".to_string()),
816 duration: start.elapsed(),
817 outputs: Vec::new(),
818 },
819 }
820 } else {
821 match executor.execute(task).await {
822 Ok(result) => result,
823 Err(e) => TaskResult {
824 task_id: task.id,
825 status: TaskState::Failed,
826 data: None,
827 error: Some(e.to_string()),
828 duration: start.elapsed(),
829 outputs: Vec::new(),
830 },
831 }
832 };
833
834 debug!(
835 "Task {} completed with status: {:?}",
836 task.name, result.status
837 );
838
839 result
840 }
841
842 async fn should_run_task(&self, task: &Task, context: &ExecutionContext) -> bool {
843 // Evaluate task conditions
844 for condition in &task.conditions {
845 if !self.evaluate_condition(condition, context).await {
846 debug!("Task {} skipped due to condition: {}", task.name, condition);
847 return false;
848 }
849 }
850 true
851 }
852
853 async fn evaluate_condition(&self, condition: &str, context: &ExecutionContext) -> bool {
854 match parse_condition(condition, context) {
855 Ok(result) => result,
856 Err(err) => {
857 debug!(
858 "Condition parse error (treating as false): {} – {}",
859 condition, err
860 );
861 false
862 }
863 }
864 }
865}
866
867// ── Condition expression evaluator ──────────────────────────────────────────
868
869/// Value types that can appear in condition expressions.
870#[derive(Debug, Clone, PartialEq)]
871enum CondValue {
872 /// Integer/byte quantity (e.g. file sizes, counts).
873 Int(i64),
874 /// Floating-point number.
875 Float(f64),
876 /// String value.
877 Str(String),
878 /// Boolean literal.
879 Bool(bool),
880}
881
882impl CondValue {
883 /// Attempt to compare two values using a standard ordering.
884 fn partial_cmp_values(&self, other: &Self) -> Option<std::cmp::Ordering> {
885 match (self, other) {
886 (Self::Int(a), Self::Int(b)) => a.partial_cmp(b),
887 (Self::Float(a), Self::Float(b)) => a.partial_cmp(b),
888 (Self::Int(a), Self::Float(b)) => (*a as f64).partial_cmp(b),
889 (Self::Float(a), Self::Int(b)) => a.partial_cmp(&(*b as f64)),
890 (Self::Str(a), Self::Str(b)) => a.partial_cmp(b),
891 _ => None,
892 }
893 }
894
895 /// Equality comparison with type coercion between numerics.
896 fn eq_coerced(&self, other: &Self) -> bool {
897 match (self, other) {
898 (Self::Int(a), Self::Int(b)) => a == b,
899 (Self::Float(a), Self::Float(b)) => (a - b).abs() < f64::EPSILON,
900 (Self::Int(a), Self::Float(b)) => ((*a as f64) - b).abs() < f64::EPSILON,
901 (Self::Float(a), Self::Int(b)) => (a - (*b as f64)).abs() < f64::EPSILON,
902 (Self::Str(a), Self::Str(b)) => a.eq_ignore_ascii_case(b),
903 (Self::Bool(a), Self::Bool(b)) => a == b,
904 _ => false,
905 }
906 }
907}
908
909/// Resolve a variable reference from the execution context.
910///
911/// Supported paths:
912/// - `output.<key>` – looks up `key` in `context.variables` and parses the
913/// value as a `CondValue`.
914/// - Bare identifiers – also looked up in `context.variables`.
915fn resolve_variable(path: &str, context: &ExecutionContext) -> Option<CondValue> {
916 let key = path.trim_start_matches("output.");
917 let json_val = context.get_variable(key)?;
918 json_to_cond_value(&json_val)
919}
920
921/// Convert a `serde_json::Value` to a `CondValue`.
922fn json_to_cond_value(v: &serde_json::Value) -> Option<CondValue> {
923 match v {
924 serde_json::Value::Bool(b) => Some(CondValue::Bool(*b)),
925 serde_json::Value::Number(n) => {
926 if let Some(i) = n.as_i64() {
927 Some(CondValue::Int(i))
928 } else {
929 n.as_f64().map(CondValue::Float)
930 }
931 }
932 serde_json::Value::String(s) => Some(CondValue::Str(s.clone())),
933 _ => None,
934 }
935}
936
937/// Parse a literal token (number with optional unit, boolean, or quoted string)
938/// into a `CondValue`.
939///
940/// Unit suffixes supported:
941/// - Bytes : `B`, `KB`, `MB`, `GB`, `TB` (powers of 1024)
942/// - Time : `ms`, `s`, `m`, `h`
943/// - Bare numbers are treated as integers or floats.
944fn parse_literal(token: &str) -> Option<CondValue> {
945 let t = token.trim().trim_matches(|c| c == '"' || c == '\'');
946
947 // Boolean literals
948 match t.to_lowercase().as_str() {
949 "true" => return Some(CondValue::Bool(true)),
950 "false" => return Some(CondValue::Bool(false)),
951 _ => {}
952 }
953
954 // Try numeric with byte-size suffix (case-insensitive)
955 let lower = t.to_lowercase();
956 let byte_units: &[(&str, i64)] = &[
957 ("tb", 1024 * 1024 * 1024 * 1024),
958 ("gb", 1024 * 1024 * 1024),
959 ("mb", 1024 * 1024),
960 ("kb", 1024),
961 ("b", 1),
962 ];
963 for &(suffix, multiplier) in byte_units {
964 if let Some(num_str) = lower.strip_suffix(suffix) {
965 let num_str = num_str.trim();
966 if let Ok(n) = num_str.parse::<f64>() {
967 return Some(CondValue::Int((n * multiplier as f64) as i64));
968 }
969 }
970 }
971
972 // Duration suffixes
973 let duration_units: &[(&str, i64)] =
974 &[("ms", 1), ("s", 1_000), ("m", 60_000), ("h", 3_600_000)];
975 for &(suffix, multiplier_ms) in duration_units {
976 if let Some(num_str) = lower.strip_suffix(suffix) {
977 let num_str = num_str.trim();
978 if let Ok(n) = num_str.parse::<f64>() {
979 return Some(CondValue::Int((n * multiplier_ms as f64) as i64));
980 }
981 }
982 }
983
984 // Plain integer
985 if let Ok(i) = t.parse::<i64>() {
986 return Some(CondValue::Int(i));
987 }
988
989 // Plain float
990 if let Ok(f) = t.parse::<f64>() {
991 return Some(CondValue::Float(f));
992 }
993
994 // Fall back to string value (handles codec names etc.)
995 Some(CondValue::Str(t.to_string()))
996}
997
998/// Parse and evaluate a single condition expression against the execution context.
999///
1000/// Grammar (simplified):
1001/// ```text
1002/// condition := operand operator operand
1003/// | "!" operand
1004/// operand := variable | literal
1005/// variable := identifier ("." identifier)*
1006/// operator := "==" | "!=" | ">" | ">=" | "<" | "<="
1007/// | "contains" | "startswith" | "endswith"
1008/// literal := number [unit] | bool | quoted-string
1009/// ```
1010///
1011/// Examples:
1012/// - `"output.size > 1MB"`
1013/// - `"duration >= 60s"`
1014/// - `"codec == av1"`
1015/// - `"output.status == completed"`
1016/// - `"count > 0"`
1017/// - `"!failed"`
1018pub fn parse_condition(
1019 condition: &str,
1020 context: &ExecutionContext,
1021) -> std::result::Result<bool, String> {
1022 let cond = condition.trim();
1023
1024 // Handle negation: !<expr>
1025 if let Some(rest) = cond.strip_prefix('!') {
1026 return parse_condition(rest.trim(), context).map(|v| !v);
1027 }
1028
1029 // Try to split on a binary operator (longest match first to avoid
1030 // ">" matching before ">=")
1031 let operators = &[
1032 ">=",
1033 "<=",
1034 "!=",
1035 "==",
1036 ">",
1037 "<",
1038 "contains",
1039 "startswith",
1040 "endswith",
1041 ];
1042
1043 for &op in operators {
1044 // Use case-insensitive search for word operators
1045 let split_pos = if op.chars().all(char::is_alphabetic) {
1046 // word operator – find as whole word
1047 let lower = cond.to_lowercase();
1048 let needle = format!(" {op} ");
1049 lower
1050 .find(&needle)
1051 .map(|p| (p, p + needle.len() - 1, op.len()))
1052 } else {
1053 // symbol operator
1054 cond.find(op).map(|p| (p, p + op.len(), op.len()))
1055 };
1056
1057 let Some((lhs_end, rhs_start, _)) = split_pos else {
1058 continue;
1059 };
1060
1061 let lhs_str = cond[..lhs_end].trim();
1062 let rhs_str = cond[rhs_start..].trim();
1063
1064 // Resolve left-hand side: try as variable first, then literal
1065 let lhs = resolve_variable(lhs_str, context).or_else(|| parse_literal(lhs_str));
1066
1067 // Resolve right-hand side
1068 let rhs = resolve_variable(rhs_str, context).or_else(|| parse_literal(rhs_str));
1069
1070 let lhs = lhs.ok_or_else(|| format!("Cannot resolve LHS: {lhs_str}"))?;
1071 let rhs = rhs.ok_or_else(|| format!("Cannot resolve RHS: {rhs_str}"))?;
1072
1073 let result = match op {
1074 "==" => lhs.eq_coerced(&rhs),
1075 "!=" => !lhs.eq_coerced(&rhs),
1076 ">" => lhs
1077 .partial_cmp_values(&rhs)
1078 .is_some_and(|o| o == std::cmp::Ordering::Greater),
1079 ">=" => lhs
1080 .partial_cmp_values(&rhs)
1081 .is_some_and(|o| o != std::cmp::Ordering::Less),
1082 "<" => lhs
1083 .partial_cmp_values(&rhs)
1084 .is_some_and(|o| o == std::cmp::Ordering::Less),
1085 "<=" => lhs
1086 .partial_cmp_values(&rhs)
1087 .is_some_and(|o| o != std::cmp::Ordering::Greater),
1088 "contains" => {
1089 if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
1090 l.to_lowercase().contains(&r.to_lowercase())
1091 } else {
1092 false
1093 }
1094 }
1095 "startswith" => {
1096 if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
1097 l.to_lowercase().starts_with(&r.to_lowercase())
1098 } else {
1099 false
1100 }
1101 }
1102 "endswith" => {
1103 if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
1104 l.to_lowercase().ends_with(&r.to_lowercase())
1105 } else {
1106 false
1107 }
1108 }
1109 _ => false,
1110 };
1111
1112 return Ok(result);
1113 }
1114
1115 // No operator found: treat the whole expression as a boolean variable lookup
1116 if let Some(val) = resolve_variable(cond, context) {
1117 return Ok(match val {
1118 CondValue::Bool(b) => b,
1119 CondValue::Int(i) => i != 0,
1120 CondValue::Float(f) => f != 0.0,
1121 CondValue::Str(s) => !s.is_empty() && s.to_lowercase() != "false",
1122 });
1123 }
1124
1125 // Unknown condition – default to true so existing workflows are not broken
1126 debug!("Condition not resolvable, defaulting to true: {}", cond);
1127 Ok(true)
1128}
1129
1130/// Workflow execution result.
1131#[derive(Debug)]
1132pub struct ExecutionResult {
1133 /// Workflow ID.
1134 pub workflow_id: WorkflowId,
1135 /// Final workflow state.
1136 pub state: WorkflowState,
1137 /// Total execution duration.
1138 pub duration: Duration,
1139 /// Results for all tasks.
1140 pub task_results: HashMap<TaskId, TaskResult>,
1141 /// Serialized checkpoint JSON when the workflow was paused mid-execution.
1142 /// `None` when the workflow ran to completion or failure.
1143 pub checkpoint: Option<String>,
1144}
1145
1146/// Default task executor implementation.
1147pub struct DefaultTaskExecutor;
1148
1149#[async_trait]
1150impl TaskExecutor for DefaultTaskExecutor {
1151 async fn execute(&self, task: &Task) -> Result<TaskResult> {
1152 use crate::task::TaskType;
1153
1154 let start = Instant::now();
1155
1156 let result: Result<()> = match &task.task_type {
1157 TaskType::Wait { duration } => {
1158 tokio::time::sleep(*duration).await;
1159 Ok(())
1160 }
1161 TaskType::HttpRequest {
1162 url,
1163 method,
1164 headers: _,
1165 body: _,
1166 } => {
1167 debug!("HTTP {:?} request to: {}", method, url);
1168 // HTTP client integration would go here (reqwest / hyper).
1169 // At the workflow-engine layer we log the intent and succeed;
1170 // callers that need real HTTP should provide a custom TaskExecutor.
1171 info!("HTTP {} {}", format!("{:?}", method).to_uppercase(), url);
1172 Ok(())
1173 }
1174 TaskType::Transcode {
1175 input,
1176 output,
1177 preset,
1178 params: _,
1179 } => {
1180 info!("Transcode: {:?} → {:?} (preset: {})", input, output, preset);
1181 // Validate that the input path exists before handing off to a
1182 // transcode engine. The actual codec pipeline is implemented in
1183 // oximedia-transcode; this executor records the intent and
1184 // succeeds so the workflow graph continues.
1185 if !input.exists() {
1186 return Err(WorkflowError::generic(format!(
1187 "Transcode input not found: {}",
1188 input.display()
1189 )));
1190 }
1191 // Ensure parent directory of output exists.
1192 if let Some(parent) = output.parent() {
1193 if !parent.as_os_str().is_empty() {
1194 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1195 WorkflowError::generic(format!(
1196 "Cannot create output directory {}: {e}",
1197 parent.display()
1198 ))
1199 })?;
1200 }
1201 }
1202 info!("Transcode task recorded for {:?}", output);
1203 Ok(())
1204 }
1205 TaskType::QualityControl {
1206 input,
1207 profile,
1208 rules,
1209 } => {
1210 info!(
1211 "QualityControl: {:?} profile={} rules={:?}",
1212 input, profile, rules
1213 );
1214 if !input.exists() {
1215 return Err(WorkflowError::generic(format!(
1216 "QC input not found: {}",
1217 input.display()
1218 )));
1219 }
1220 // QC validation logic lives in oximedia-qc; here we confirm
1221 // the file is reachable and log that QC was requested.
1222 let metadata = tokio::fs::metadata(input)
1223 .await
1224 .map_err(|e| WorkflowError::generic(format!("QC metadata error: {e}")))?;
1225 info!(
1226 "QC target size: {} bytes, profile: {}",
1227 metadata.len(),
1228 profile
1229 );
1230 Ok(())
1231 }
1232 TaskType::Transfer {
1233 source,
1234 destination,
1235 protocol,
1236 options: _,
1237 } => {
1238 use crate::task::TransferProtocol;
1239 info!("Transfer: {} → {} via {:?}", source, destination, protocol);
1240 // For local-filesystem transfers we perform the copy directly.
1241 // Remote protocols (S3, SFTP, FTP, rsync, HTTP) are handled by
1242 // dedicated transfer agents; this executor logs the request.
1243 match protocol {
1244 TransferProtocol::Local => {
1245 let src_path = std::path::Path::new(source.as_str());
1246 let dst_path = std::path::Path::new(destination.as_str());
1247 if let Some(parent) = dst_path.parent() {
1248 if !parent.as_os_str().is_empty() {
1249 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1250 WorkflowError::generic(format!(
1251 "Cannot create destination dir: {e}"
1252 ))
1253 })?;
1254 }
1255 }
1256 tokio::fs::copy(src_path, dst_path).await.map_err(|e| {
1257 WorkflowError::generic(format!(
1258 "Local copy {} → {} failed: {e}",
1259 src_path.display(),
1260 dst_path.display()
1261 ))
1262 })?;
1263 info!("Local transfer complete: {} → {}", source, destination);
1264 }
1265 other => {
1266 info!(
1267 "Remote transfer ({:?}) queued: {} → {}",
1268 other, source, destination
1269 );
1270 }
1271 }
1272 Ok(())
1273 }
1274 TaskType::Notification {
1275 channel,
1276 message,
1277 metadata: _,
1278 } => {
1279 use crate::task::NotificationChannel;
1280 match channel {
1281 NotificationChannel::Email { to, subject } => {
1282 info!(
1283 "Notification [Email] to={:?} subject={:?}: {}",
1284 to, subject, message
1285 );
1286 }
1287 NotificationChannel::Webhook { url } => {
1288 info!("Notification [Webhook] url={}: {}", url, message);
1289 }
1290 NotificationChannel::Slack {
1291 channel: slack_channel,
1292 webhook_url,
1293 } => {
1294 info!(
1295 "Notification [Slack] channel={} url={}: {}",
1296 slack_channel, webhook_url, message
1297 );
1298 }
1299 NotificationChannel::Discord { webhook_url } => {
1300 info!("Notification [Discord] url={}: {}", webhook_url, message);
1301 }
1302 }
1303 Ok(())
1304 }
1305 TaskType::CustomScript { script, args, env } => {
1306 info!(
1307 "CustomScript: {:?} args={:?} env_vars={}",
1308 script,
1309 args,
1310 env.len()
1311 );
1312 if !script.exists() {
1313 return Err(WorkflowError::generic(format!(
1314 "Script not found: {}",
1315 script.display()
1316 )));
1317 }
1318 // Spawn the script as a child process via tokio::process.
1319 let mut cmd = tokio::process::Command::new(script);
1320 cmd.args(args);
1321 for (k, v) in env {
1322 cmd.env(k, v);
1323 }
1324 let status = cmd.status().await.map_err(|e| {
1325 WorkflowError::generic(format!("Script {:?} failed to launch: {e}", script))
1326 })?;
1327 if !status.success() {
1328 return Err(WorkflowError::generic(format!(
1329 "Script {:?} exited with status: {}",
1330 script, status
1331 )));
1332 }
1333 info!("Script {:?} completed successfully", script);
1334 Ok(())
1335 }
1336 TaskType::Analysis {
1337 input,
1338 analyses,
1339 output,
1340 } => {
1341 info!(
1342 "Analysis: {:?} types={:?} output={:?}",
1343 input, analyses, output
1344 );
1345 if !input.exists() {
1346 return Err(WorkflowError::generic(format!(
1347 "Analysis input not found: {}",
1348 input.display()
1349 )));
1350 }
1351 // If an output path was requested, ensure its parent exists.
1352 if let Some(out_path) = output {
1353 if let Some(parent) = out_path.parent() {
1354 if !parent.as_os_str().is_empty() {
1355 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1356 WorkflowError::generic(format!(
1357 "Cannot create analysis output dir: {e}"
1358 ))
1359 })?;
1360 }
1361 }
1362 }
1363 // Analysis engines live in oximedia-quality / oximedia-scene etc.
1364 // This executor records the request and succeeds; real analysis
1365 // is performed by the domain-specific pipeline.
1366 info!("Analysis task recorded for {:?}", input);
1367 Ok(())
1368 }
1369 TaskType::Conditional {
1370 condition,
1371 true_task,
1372 false_task,
1373 } => {
1374 // Evaluate the condition expression (simple boolean string parse;
1375 // full expression evaluation would use the ExecutionContext variables).
1376 let condition_result = match condition.trim().to_lowercase().as_str() {
1377 "true" | "1" | "yes" => true,
1378 "false" | "0" | "no" => false,
1379 other => {
1380 debug!(
1381 "Condition not resolvable as literal, defaulting to true: {}",
1382 other
1383 );
1384 true
1385 }
1386 };
1387
1388 let branch_task = if condition_result {
1389 true_task.as_deref()
1390 } else {
1391 false_task.as_deref()
1392 };
1393
1394 if let Some(inner_task) = branch_task {
1395 info!(
1396 "Conditional branch selected: condition={} task={}",
1397 condition_result, inner_task.name
1398 );
1399 // Recursively execute the selected branch task.
1400 let branch_result = self.execute(inner_task).await?;
1401 if !matches!(branch_result.status, TaskState::Completed) {
1402 return Err(WorkflowError::generic(format!(
1403 "Conditional branch task '{}' failed: {}",
1404 inner_task.name,
1405 branch_result.error.as_deref().unwrap_or("unknown")
1406 )));
1407 }
1408 } else {
1409 debug!("Conditional task: selected branch has no task, skipping");
1410 }
1411 Ok(())
1412 }
1413 };
1414
1415 match result {
1416 Ok(()) => Ok(TaskResult {
1417 task_id: task.id,
1418 status: TaskState::Completed,
1419 data: None,
1420 error: None,
1421 duration: start.elapsed(),
1422 outputs: Vec::new(),
1423 }),
1424 Err(e) => Ok(TaskResult {
1425 task_id: task.id,
1426 status: TaskState::Failed,
1427 data: None,
1428 error: Some(e.to_string()),
1429 duration: start.elapsed(),
1430 outputs: Vec::new(),
1431 }),
1432 }
1433 }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438 use super::*;
1439 use crate::task::{Task, TaskType};
1440 use std::path::PathBuf;
1441
1442 #[test]
1443 fn test_execution_context_creation() {
1444 let workflow_id = WorkflowId::new();
1445 let ctx = ExecutionContext::new(workflow_id);
1446 assert_eq!(ctx.workflow_id, workflow_id);
1447 }
1448
1449 #[test]
1450 fn test_execution_context_variables() {
1451 let ctx = ExecutionContext::new(WorkflowId::new());
1452 ctx.set_variable("key".to_string(), serde_json::json!("value"));
1453 assert_eq!(ctx.get_variable("key"), Some(serde_json::json!("value")));
1454 }
1455
1456 #[test]
1457 fn test_execution_context_results() {
1458 let ctx = ExecutionContext::new(WorkflowId::new());
1459 let task_id = TaskId::new();
1460 let result = TaskResult {
1461 task_id,
1462 status: TaskState::Completed,
1463 data: None,
1464 error: None,
1465 duration: Duration::from_secs(1),
1466 outputs: Vec::new(),
1467 };
1468
1469 ctx.store_result(task_id, result.clone());
1470 let retrieved = ctx.get_result(&task_id);
1471 assert!(retrieved.is_some());
1472 }
1473
1474 #[tokio::test]
1475 async fn test_default_executor_wait_task() {
1476 let executor = DefaultTaskExecutor;
1477 let task = Task::new(
1478 "wait-task",
1479 TaskType::Wait {
1480 duration: Duration::from_millis(10),
1481 },
1482 );
1483
1484 let result = executor
1485 .execute(&task)
1486 .await
1487 .expect("should succeed in test");
1488 assert_eq!(result.status, TaskState::Completed);
1489 }
1490
1491 #[tokio::test]
1492 async fn test_workflow_executor_creation() {
1493 let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor))
1494 .with_max_concurrent(2)
1495 .with_timeout(Duration::from_secs(60));
1496
1497 assert_eq!(executor.max_concurrent, 2);
1498 assert!(executor.timeout.is_some());
1499 }
1500
1501 #[tokio::test]
1502 async fn test_simple_workflow_execution() {
1503 let mut workflow = Workflow::new("test-workflow");
1504 let task = Task::new(
1505 "test-task",
1506 TaskType::Wait {
1507 duration: Duration::from_millis(10),
1508 },
1509 );
1510 workflow.add_task(task);
1511
1512 let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor));
1513 let result = executor
1514 .execute(&mut workflow)
1515 .await
1516 .expect("should succeed in test");
1517
1518 assert_eq!(result.state, WorkflowState::Completed);
1519 assert_eq!(result.task_results.len(), 1);
1520 }
1521
1522 // -----------------------------------------------------------------------
1523 // Regression tests: `WorkflowExecutor::execute` must never fabricate
1524 // `WorkflowState::Completed` while silently dropping tasks. See the
1525 // "Ordering invariant" section on `WorkflowExecutor::execute` and the
1526 // "Scheduling correctness" module doc.
1527 // -----------------------------------------------------------------------
1528
1529 /// A `TaskExecutor` that records the order in which tasks are actually
1530 /// executed (by name) into a shared, `Arc`-owned log, so tests can assert
1531 /// dependency-respecting scheduling order.
1532 struct OrderRecordingExecutor {
1533 order: Arc<Mutex<Vec<String>>>,
1534 }
1535
1536 #[async_trait]
1537 impl TaskExecutor for OrderRecordingExecutor {
1538 async fn execute(&self, task: &Task) -> Result<TaskResult> {
1539 // A short delay makes it likely that, if the scheduler ever
1540 // incorrectly started a dependent task before its dependency
1541 // actually finished, the ordering violation would surface here.
1542 tokio::time::sleep(Duration::from_millis(5)).await;
1543 self.order
1544 .lock()
1545 .expect("order mutex poisoned in test")
1546 .push(task.name.clone());
1547 Ok(TaskResult {
1548 task_id: task.id,
1549 status: TaskState::Completed,
1550 data: None,
1551 error: None,
1552 duration: Duration::ZERO,
1553 outputs: Vec::new(),
1554 })
1555 }
1556 }
1557
1558 /// Regression test for the scheduling bug where `execute()` scanned
1559 /// `task_order` with a single-pass iterator: a task whose dependencies
1560 /// were not yet satisfied got `continue`d past, and because the shared
1561 /// iterator never yielded it again, every non-root task in a dependency
1562 /// chain was silently dropped while the workflow still reported
1563 /// `Completed`.
1564 ///
1565 /// With a real fixpoint scheduler, all three tasks in `a -> b -> c` must
1566 /// run, and must run in dependency order.
1567 #[tokio::test]
1568 async fn test_dependency_chain_runs_all_steps_in_order() {
1569 let mut workflow = Workflow::new("chain-workflow");
1570
1571 let task_a = Task::new(
1572 "a",
1573 TaskType::Wait {
1574 duration: Duration::from_millis(1),
1575 },
1576 );
1577 let task_b = Task::new(
1578 "b",
1579 TaskType::Wait {
1580 duration: Duration::from_millis(1),
1581 },
1582 );
1583 let task_c = Task::new(
1584 "c",
1585 TaskType::Wait {
1586 duration: Duration::from_millis(1),
1587 },
1588 );
1589
1590 let id_a = workflow.add_task(task_a);
1591 let id_b = workflow.add_task(task_b);
1592 let id_c = workflow.add_task(task_c);
1593
1594 workflow
1595 .add_edge(id_a, id_b)
1596 .expect("should succeed in test");
1597 workflow
1598 .add_edge(id_b, id_c)
1599 .expect("should succeed in test");
1600
1601 let order: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1602 let task_executor = Arc::new(OrderRecordingExecutor {
1603 order: order.clone(),
1604 });
1605 let executor = WorkflowExecutor::new(task_executor);
1606
1607 let result = tokio::time::timeout(Duration::from_secs(10), executor.execute(&mut workflow))
1608 .await
1609 .expect("workflow execution should not hang")
1610 .expect("workflow execution should succeed in test");
1611
1612 assert_eq!(
1613 result.state,
1614 WorkflowState::Completed,
1615 "all three tasks succeeded, so the workflow must report Completed"
1616 );
1617
1618 // The core regression check: every task in the chain actually ran,
1619 // not just the root `a`.
1620 assert_eq!(
1621 result.task_results.len(),
1622 3,
1623 "all three chained tasks must produce a result, not just the root: {:?}",
1624 result.task_results
1625 );
1626 for id in [id_a, id_b, id_c] {
1627 let status = result
1628 .task_results
1629 .get(&id)
1630 .expect("every task in the chain must have a result")
1631 .status;
1632 assert_eq!(status, TaskState::Completed);
1633 }
1634
1635 // And they must have run in dependency order: a before b before c.
1636 let recorded = order.lock().expect("order mutex poisoned in test").clone();
1637 assert_eq!(
1638 recorded,
1639 vec!["a".to_string(), "b".to_string(), "c".to_string()],
1640 "tasks must execute in dependency order, got {recorded:?}"
1641 );
1642 }
1643
1644 /// A cyclic dependency graph must never be reported as `Completed`.
1645 /// `Workflow::validate()` (called at the top of `execute()`) rejects
1646 /// cycles before any task runs; this pins that contract at the
1647 /// `WorkflowExecutor::execute` boundary as a regression guard.
1648 #[tokio::test]
1649 async fn test_cycle_returns_err_not_completed() {
1650 let mut workflow = Workflow::new("cyclic-workflow");
1651 let task1 = Task::new(
1652 "task1",
1653 TaskType::Wait {
1654 duration: Duration::from_millis(1),
1655 },
1656 );
1657 let task2 = Task::new(
1658 "task2",
1659 TaskType::Wait {
1660 duration: Duration::from_millis(1),
1661 },
1662 );
1663 let id1 = workflow.add_task(task1);
1664 let id2 = workflow.add_task(task2);
1665 workflow.add_edge(id1, id2).expect("should succeed in test");
1666 workflow.add_edge(id2, id1).expect("should succeed in test");
1667
1668 let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor));
1669 let outcome =
1670 tokio::time::timeout(Duration::from_secs(10), executor.execute(&mut workflow))
1671 .await
1672 .expect("workflow execution must not hang on a cyclic graph");
1673
1674 assert!(
1675 outcome.is_err(),
1676 "a cyclic dependency graph must be rejected, never reported as Completed"
1677 );
1678 assert_ne!(workflow.state, WorkflowState::Completed);
1679 }
1680
1681 /// A real failure on a **non-root** task (`b`, which depends on `a`) must
1682 /// still be observed and reported, and must cascade to `c` (which
1683 /// depends on `b`) instead of being silently dropped.
1684 ///
1685 /// This specifically pins the single-pass-iterator failure mode: `b`
1686 /// cannot possibly be ready during the very first scan (its dependency
1687 /// `a` is still in flight), so under the old scheduler it was
1688 /// `continue`d past *forever* and never retried once `a` finished --
1689 /// meaning `b` never actually ran, never reported failure, and
1690 /// `failed_tasks` stayed empty. The workflow then dishonestly reported
1691 /// `Completed` even though the task that was designed to fail never got
1692 /// a chance to execute at all. With a real fixpoint scheduler `b` is
1693 /// retried after `a` completes, actually runs, actually fails, and the
1694 /// failure is recorded -- so the workflow must resolve promptly (not
1695 /// hang) and report `Failed`, with `c` cascaded rather than vanishing.
1696 #[tokio::test]
1697 async fn test_non_root_failure_is_not_silently_dropped() {
1698 let mut workflow = Workflow::new("non-root-failure-workflow");
1699 workflow.config.fail_fast = false;
1700
1701 let task_a = Task::new(
1702 "a",
1703 TaskType::Wait {
1704 duration: Duration::from_millis(1),
1705 },
1706 );
1707 // `b` fails for real: a `CustomScript` task pointing at a script
1708 // that does not exist on disk. Crucially, `b` is NOT a root task,
1709 // so it can never be ready on the very first scheduling pass.
1710 let task_b = Task::new(
1711 "b",
1712 TaskType::CustomScript {
1713 script: PathBuf::from("/nonexistent/does-not-exist.sh"),
1714 args: Vec::new(),
1715 env: HashMap::new(),
1716 },
1717 );
1718 let task_c = Task::new(
1719 "c",
1720 TaskType::Wait {
1721 duration: Duration::from_millis(1),
1722 },
1723 );
1724
1725 let id_a = workflow.add_task(task_a);
1726 let id_b = workflow.add_task(task_b);
1727 let id_c = workflow.add_task(task_c);
1728
1729 workflow
1730 .add_edge(id_a, id_b)
1731 .expect("should succeed in test");
1732 workflow
1733 .add_edge(id_b, id_c)
1734 .expect("should succeed in test");
1735
1736 let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor));
1737
1738 let result = tokio::time::timeout(Duration::from_secs(10), executor.execute(&mut workflow))
1739 .await
1740 .expect("workflow execution must not hang on a downstream failure")
1741 .expect("execute() returns Ok with a Failed state (not an Err) for a cascaded skip");
1742
1743 assert_eq!(
1744 result.state,
1745 WorkflowState::Failed,
1746 "a real non-root failure must yield a Failed workflow, never a fabricated Completed \
1747 (the failing task must not be silently dropped by the scheduler)"
1748 );
1749 assert_ne!(result.state, WorkflowState::Completed);
1750
1751 // `b` must have actually run (and been recorded as failed), not
1752 // vanished: its result must be present and Failed.
1753 let b_status = result
1754 .task_results
1755 .get(&id_b)
1756 .expect("the failing non-root task must have actually run and produced a result")
1757 .status;
1758 assert_eq!(
1759 b_status,
1760 TaskState::Failed,
1761 "b must be recorded as Failed, not silently skipped"
1762 );
1763 }
1764}