mod support;
use cano::prelude::*;
use parking_lot::Mutex;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
use support::{Flow, MemStore};
struct PagedMigration {
flaky_cursor: u32,
failures_remaining: Arc<AtomicU32>,
calls: Arc<Mutex<Vec<Option<u32>>>>,
}
#[task::stepped]
impl SteppedTask<Flow> for PagedMigration {
type Cursor = u32;
fn config(&self) -> TaskConfig {
TaskConfig::minimal().with_fixed_retry(2, Duration::from_millis(1)) }
async fn step(
&self,
_res: &Resources,
cursor: Option<u32>,
) -> Result<StepOutcome<u32, Flow>, CanoError> {
self.calls.lock().push(cursor);
if cursor == Some(self.flaky_cursor) && self.failures_remaining.load(Ordering::SeqCst) > 0 {
self.failures_remaining.fetch_sub(1, Ordering::SeqCst);
return Err(CanoError::task_execution("page fetch flaked"));
}
let n = cursor.unwrap_or(0);
if n >= 5 {
Ok(StepOutcome::Done(TaskResult::Single(Flow::Done)))
} else {
Ok(StepOutcome::More(n + 1))
}
}
}
#[tokio::test]
async fn stepped_transient_error_retries_same_cursor_without_restarting() {
let calls = Arc::new(Mutex::new(Vec::new()));
let workflow = Workflow::bare()
.register_stepped(
Flow::Work,
PagedMigration {
flaky_cursor: 3,
failures_remaining: Arc::new(AtomicU32::new(2)),
calls: calls.clone(),
},
)
.add_exit_state(Flow::Done);
let result = workflow
.orchestrate(Flow::Work, CancellationToken::disabled())
.await
.unwrap();
assert_eq!(result, Flow::Done);
assert_eq!(
*calls.lock(),
vec![
None,
Some(1),
Some(2),
Some(3), Some(3), Some(3), Some(4),
Some(5),
],
"retries pin the cursor; earlier pages are never refetched"
);
}
struct BlockingMigration {
blocked_at: u32,
unblocked: Arc<AtomicBool>,
calls: Arc<Mutex<Vec<Option<u32>>>>,
}
#[task::stepped]
impl SteppedTask<Flow> for BlockingMigration {
type Cursor = u32;
fn config(&self) -> TaskConfig {
TaskConfig::minimal() }
async fn step(
&self,
_res: &Resources,
cursor: Option<u32>,
) -> Result<StepOutcome<u32, Flow>, CanoError> {
self.calls.lock().push(cursor);
if cursor == Some(self.blocked_at) && !self.unblocked.load(Ordering::SeqCst) {
return Err(CanoError::task_execution("downstream outage"));
}
let n = cursor.unwrap_or(0);
if n >= 6 {
Ok(StepOutcome::Done(TaskResult::Single(Flow::Done)))
} else {
Ok(StepOutcome::More(n + 1))
}
}
}
#[tokio::test]
async fn stepped_hard_failure_resumes_from_last_persisted_cursor() {
let calls = Arc::new(Mutex::new(Vec::new()));
let unblocked = Arc::new(AtomicBool::new(false));
let store = Arc::new(MemStore::default());
let workflow = Workflow::bare()
.register_stepped(
Flow::Work,
BlockingMigration {
blocked_at: 4,
unblocked: unblocked.clone(),
calls: calls.clone(),
},
)
.add_exit_state(Flow::Done)
.with_checkpoint_store(store.clone())
.with_workflow_id("stepped-outage");
let err = workflow
.orchestrate(Flow::Work, CancellationToken::disabled())
.await
.unwrap_err();
assert_eq!(err.category(), "task_execution", "got: {err}");
assert_eq!(
*calls.lock(),
vec![None, Some(1), Some(2), Some(3), Some(4)]
);
assert_eq!(
store.last_cursor("stepped-outage", "Work"),
Some(4),
"the cursor reached before the failure is durably persisted"
);
unblocked.store(true, Ordering::SeqCst);
let result = workflow
.resume_from("stepped-outage", CancellationToken::disabled())
.await
.unwrap();
assert_eq!(result, Flow::Done);
let all = calls.lock().clone();
assert_eq!(
all[5..],
[Some(4), Some(5), Some(6)],
"resume re-runs only the failed step and the remainder"
);
assert_eq!(
all.iter().filter(|c| c.is_none()).count(),
1,
"the loop never restarted from the beginning"
);
assert!(
store.rows("stepped-outage").is_empty(),
"cleared on success"
);
}