a3s-flow 1.1.0

Durable workflow engine and Rust SDK for A3S
Documentation
use super::*;

struct CoordinatedDueStore {
    inner: InMemoryEventStore,
    scan_barrier: Barrier,
}

impl CoordinatedDueStore {
    fn new() -> Self {
        Self {
            inner: InMemoryEventStore::new(),
            scan_barrier: Barrier::new(2),
        }
    }
}

#[async_trait]
impl FlowEventStore for CoordinatedDueStore {
    async fn append(&self, run_id: &str, event: FlowEvent) -> a3s_flow::Result<FlowEventEnvelope> {
        self.inner.append(run_id, event).await
    }

    async fn append_if_sequence(
        &self,
        run_id: &str,
        expected_sequence: u64,
        event: FlowEvent,
    ) -> a3s_flow::Result<FlowEventEnvelope> {
        self.inner
            .append_if_sequence(run_id, expected_sequence, event)
            .await
    }

    async fn list(&self, run_id: &str) -> a3s_flow::Result<Vec<FlowEventEnvelope>> {
        self.inner.list(run_id).await
    }

    async fn list_run_ids(&self) -> a3s_flow::Result<Vec<String>> {
        self.inner.list_run_ids().await
    }

    async fn list_due_wakeups(&self, now: DateTime<Utc>) -> a3s_flow::Result<Vec<ScheduledWakeup>> {
        let due = self.inner.list_due_wakeups(now).await?;
        if !due.is_empty() {
            self.scan_barrier.wait().await;
        }
        Ok(due)
    }
}

#[tokio::test]
async fn resume_wait_redelivery_is_idempotent_after_terminal_completion() {
    let now = Utc::now();
    let engine = FlowEngine::in_memory(Arc::new(InputSleepRuntime));
    let run_id = engine
        .start(
            spec(),
            json!({ "resume_at": (now - ChronoDuration::seconds(1)).to_rfc3339() }),
        )
        .await
        .unwrap();

    engine.resume_wait(&run_id, "nap").await.unwrap();
    let committed_len = engine.history(&run_id).await.unwrap().len();
    let committed = engine.snapshot(&run_id).await.unwrap();
    assert_eq!(committed.status, WorkflowRunStatus::Completed);
    assert_eq!(committed.waits["nap"].status, WaitStatus::Completed);
    engine.resume_wait(&run_id, "nap").await.unwrap();

    let snapshot = engine.snapshot(&run_id).await.unwrap();
    assert_eq!(snapshot.status, WorkflowRunStatus::Completed);
    assert_eq!(snapshot.waits["nap"].status, WaitStatus::Completed);
    assert_eq!(engine.history(&run_id).await.unwrap().len(), committed_len);
}

#[tokio::test]
async fn resume_wait_redelivery_is_ignored_after_cancellation() {
    let engine = FlowEngine::in_memory(Arc::new(InputSleepRuntime));
    let run_id = engine
        .start(
            spec(),
            json!({ "resume_at": (Utc::now() + ChronoDuration::hours(1)).to_rfc3339() }),
        )
        .await
        .unwrap();
    engine
        .force_cancel(&run_id, Some("timer no longer applies".to_string()))
        .await
        .unwrap();
    let committed_len = engine.history(&run_id).await.unwrap().len();
    let cancelled = engine.snapshot(&run_id).await.unwrap();
    assert_eq!(cancelled.status, WorkflowRunStatus::Cancelled);
    assert_eq!(cancelled.waits["nap"].status, WaitStatus::Waiting);

    engine.resume_wait(&run_id, "nap").await.unwrap();

    let snapshot = engine.snapshot(&run_id).await.unwrap();
    assert_eq!(snapshot.status, WorkflowRunStatus::Cancelled);
    assert_eq!(snapshot.waits["nap"].status, WaitStatus::Waiting);
    assert_eq!(engine.history(&run_id).await.unwrap().len(), committed_len);
}

#[tokio::test]
async fn concurrent_due_scans_report_one_wait_resumption() {
    let now = Utc::now();
    let store = Arc::new(CoordinatedDueStore::new());
    let engine = FlowEngine::new(store, Arc::new(InputSleepRuntime));
    let run_id = engine
        .start(
            spec(),
            json!({ "resume_at": (now - ChronoDuration::seconds(1)).to_rfc3339() }),
        )
        .await
        .unwrap();

    let (first, second) = tokio::join!(engine.resume_due_waits(now), engine.resume_due_waits(now));
    let reported = first
        .unwrap()
        .into_iter()
        .chain(second.unwrap())
        .collect::<Vec<_>>();

    assert_eq!(reported, vec![(run_id.clone(), "nap".to_string())]);
    assert_eq!(
        engine
            .history(&run_id)
            .await
            .unwrap()
            .iter()
            .filter(|event| matches!(event.event, FlowEvent::WaitCompleted { .. }))
            .count(),
        1
    );
}