a3s-flow 0.10.1

Durable workflow engine and Rust SDK for A3S
Documentation
use a3s_flow::{
    ChildOperationReference, FlowError, FlowEvent, FlowEventStore, InMemoryEventStore,
    LocalFileEventStore, WorkflowSpec,
};
use chrono::{Duration, Utc};
use serde_json::json;

fn spec() -> WorkflowSpec {
    WorkflowSpec::rust_embedded(
        "test.store-reference-integrity",
        "1",
        "tests::store_reference_integrity",
        "main",
    )
}

fn linked_child(child_run_id: &str) -> FlowEvent {
    FlowEvent::ChildOperationLinked {
        child: ChildOperationReference::new("child", "flow.run", child_run_id)
            .with_flow_run_id(child_run_id),
    }
}

async fn create_run(store: &dyn FlowEventStore, run_id: &str) {
    store
        .append_if_sequence(
            run_id,
            0,
            FlowEvent::RunCreated {
                spec: spec(),
                input: json!({}),
            },
        )
        .await
        .unwrap();
}

async fn assert_missing_child_is_rejected(store: &dyn FlowEventStore) {
    let append_parent = "reference-append-parent";
    let conditional_parent = "reference-conditional-parent";
    let child_run_id = "reference-child";
    create_run(store, append_parent).await;
    create_run(store, conditional_parent).await;

    let append_error = store
        .append(append_parent, linked_child(child_run_id))
        .await
        .unwrap_err();
    assert!(matches!(
        append_error,
        FlowError::RunNotFound(ref run_id) if run_id == child_run_id
    ));

    let conditional_error = store
        .append_if_sequence(conditional_parent, 1, linked_child(child_run_id))
        .await
        .unwrap_err();
    assert!(matches!(
        conditional_error,
        FlowError::RunNotFound(ref run_id) if run_id == child_run_id
    ));

    assert_eq!(store.list(append_parent).await.unwrap().len(), 1);
    assert_eq!(store.list(conditional_parent).await.unwrap().len(), 1);

    create_run(store, child_run_id).await;
    assert_eq!(
        store
            .append(append_parent, linked_child(child_run_id))
            .await
            .unwrap()
            .sequence,
        2
    );
    assert_eq!(
        store
            .append_if_sequence(conditional_parent, 1, linked_child(child_run_id))
            .await
            .unwrap()
            .sequence,
        2
    );
}

#[tokio::test]
async fn in_memory_store_requires_linked_flow_runs_to_exist() {
    let store = InMemoryEventStore::new();
    assert_missing_child_is_rejected(&store).await;
}

#[tokio::test]
async fn local_file_store_requires_linked_flow_runs_to_exist_after_reopen() {
    let directory = tempfile::tempdir().unwrap();
    let store = LocalFileEventStore::new(directory.path());
    assert_missing_child_is_rejected(&store).await;
    drop(store);

    let reopened = LocalFileEventStore::new(directory.path());
    create_run(&reopened, "reference-reopen-parent").await;
    reopened
        .append("reference-reopen-parent", linked_child("reference-child"))
        .await
        .unwrap();
}

#[tokio::test]
async fn local_file_store_rejects_an_empty_linked_flow_history() {
    let directory = tempfile::tempdir().unwrap();
    let store = LocalFileEventStore::new(directory.path());
    create_run(&store, "reference-empty-parent").await;
    tokio::fs::write(directory.path().join("reference-empty-child.jsonl"), [])
        .await
        .unwrap();

    let error = store
        .append(
            "reference-empty-parent",
            linked_child("reference-empty-child"),
        )
        .await
        .unwrap_err();
    assert!(matches!(
        error,
        FlowError::RunNotFound(ref run_id) if run_id == "reference-empty-child"
    ));
    assert_eq!(store.list("reference-empty-parent").await.unwrap().len(), 1);
}

#[tokio::test]
async fn local_retention_deletes_only_complete_linked_components() {
    let directory = tempfile::tempdir().unwrap();
    let store = LocalFileEventStore::new(directory.path());
    let parent_run_id = "retention-linked-parent";
    let child_run_id = "retention-linked-child";
    create_run(&store, parent_run_id).await;
    create_run(&store, child_run_id).await;
    store
        .append(
            child_run_id,
            FlowEvent::RunCancelled {
                reason: Some("child finished".into()),
            },
        )
        .await
        .unwrap();
    store
        .append(parent_run_id, linked_child(child_run_id))
        .await
        .unwrap();

    let terminal_before = Utc::now() + Duration::minutes(1);
    assert!(store
        .prune_terminal_runs_older_than(terminal_before)
        .await
        .unwrap()
        .is_empty());
    assert!(store.list(parent_run_id).await.is_ok());
    assert!(store.list(child_run_id).await.is_ok());

    store
        .append(
            parent_run_id,
            FlowEvent::RunCancelled {
                reason: Some("parent finished".into()),
            },
        )
        .await
        .unwrap();
    assert_eq!(
        store
            .prune_terminal_runs_older_than(terminal_before)
            .await
            .unwrap(),
        vec![child_run_id.to_string(), parent_run_id.to_string()]
    );
    assert!(matches!(
        store.list(parent_run_id).await,
        Err(FlowError::RunNotFound(_))
    ));
    assert!(matches!(
        store.list(child_run_id).await,
        Err(FlowError::RunNotFound(_))
    ));
}