flowstate 0.7.0

Workflow runtime powered by finite state machines.
Documentation

flowstate

Flowstate is library for modelling multi-step processes as self-executing state machines. It is heavily inspired by the typestate pattern but with the goal of making it easier to model self-executing workflows as finite state machines.

Typestate APIs make invalid state transitions impossible. However, the caller is typically responsible for driving the state transitions. In contrast, each state in a Flowstate workflow is responsible for transitioning to the next state. This allows workflows to be self-executing.

Flowstate has zero runtime dependencies.

Basic Usage

The following is an example of a very basic workflow.

/**
 * ╔═[BasicWorkflow]════════════════════════════╗
 * ║ [StateA] ──> [StateB] ──> [WorkflowResult] ║
 * ╚════════════════════════════════════════════╝
 */

use flowstate::prelude::*;

#[derive(Workflow)]
#[flowstate(
    result = WorkflowResult,
    state_trait = BasicWorkflowState,
)]
struct BasicWorkflow<State> {
    #[state]
    _state: State,
}

#[derive(State)]
struct StateA;

impl BasicWorkflowState for BasicWorkflow<StateA> {
    fn next(self: Box<Self>) -> StaticTransition<WorkflowResult> {
        self.transition(StateB)
    }
}

#[derive(State)]
struct StateB;

impl BasicWorkflowState for BasicWorkflow<StateB> {
    fn next(self: Box<Self>) -> StaticTransition<WorkflowResult> {
        self.finish(WorkflowResult)
    }
}

#[derive(Debug, PartialEq)]
struct WorkflowResult;

#[test]
fn test_basic_workflow() {
    let workflow = BasicWorkflow::new(StateA);
    let result = workflow.run();
    assert_eq!(result, WorkflowResult);
}

Getting started

This section deals with synchronous workflows. If you're looking for documentation on async workflows, check out the section on "async workflows" under "advanced topics". It is still recommended to read this section first, as it gives an overview of some important concepts.

Add flowstate to your Cargo.toml.

[dependencies]
flowstate = "0.3"

The prelude brings all the essential types into scope.

use flowstate::prelude::*;

Next, derive the Workflow trait. Flowstate can be used without procedural macros, but it requires a little more boilerplate.

#[derive(Workflow)]
#[flowstate(
    result = MyWorkflowResult,
    state_trait = MyWorkflowState,
)]
struct MyWorkflow<State> {
    #[state]
    _state: State,
    ctx: MyWorkflowContext,
}

The #[flowstate(..)] attribute defines the result type, and the identifier for the workflow state trait.

The result type, is the type returned on completion of the workflow. If your workflow has multiple terminal states, this should be an enum representing each of those terminal states.

The state_trait, if specified, causes a trait to be generated, which should be implemented by each of the workflow states. This is optional, and we could also forgo generating this trait, and instead implement flowstate::WorkflowState for each of our states.

The #[state] attribute lets Typestate know which field stores your state.

You can also define one or more context field, such as ctx in the example above. These will be automatically propogated each time your workflow transitions to a new state.

Next, define your states.

#[derive(State)]
struct MyState;

impl MyWorkflowState for MyWorkflow<MyState> {
    fn next(self: Box<Self>) -> StaticTransition<MyWorkflowResult> {
        // Do some work...

        self.transition(MyNextState)
    }
}

The MyWorkflowState trait was generated by the #[derive(Workflow)] macro. Implementing it allows you to define the transition logic for your state. MyWorkflowState should be implemented on MyWorkflow<MyState>, not on MyState. This allows us to access context fields.

Also worth noting, is that we are returning a StaticTransition. Provided that your workflow type (e.g. MyWorkflow) does not have any generic lifetime parameters, all transitions will be 'static. If you do require a generic lifetime parameter on your workflow, then you can find more details in the section on "workflows with generic lifetimes" below.

In the above example, we return self.transition(MyNextState). This is a helper function generated by #[derive(Workflow)]. You can manually instantiate the Transition<MyWorkflowResult> type, but the transition function removes some of the boilerplate.

You can also return self.finish(result) to terminate the workflow with a result.

On occasion, you may need move out of the previous state, or access the context when constructing the next state, or result. In such cases, you may encounter borrow checker errors. To avoid these, you can use self.transition_with(|state| ...) or self.finish_with(|workflow| ...).

Finally, you can construct and run your workflow.

let workflow = MyWorkflow::new(MyState, MyWorkflowContext { /* ... */ });
let result = workflow.run();

Concepts

States

A state is any type that implements [State]. The #[derive(State)] macro implements it automatically.

#[derive(State)]
struct Loading;

#[derive(State)]
struct Processing;

#[derive(State)]
struct Done;

Workflows

A workflow is a struct generic over a State type parameter. Workflows must implement the Workflow trait. The #[derive(Workflow)] macro implements this automatically, and generates some other utility methods and traits.

One field must be annotated with #[state]; all other fields are context shared across every state. The #[derive(Workflow)] macro also requires a #[flowstate(result = T)] attribute, specifying the type the workflow produces when it terminates.

#[derive(Workflow)]
#[flowstate(result = MyResult)]
struct MyWorkflow<State> {
    #[state]
    _state: State,
    // context fields shared across all states
    input: Vec<u8>,
}

The macro generates:

  • A new(state, ...context_fields) constructor.
  • An implementation of [Workflow].
  • A MyWorkflow::transition(next_state) method, which moves to the next state, carrying context through.
  • A MyWorkflow::transition_with(map_fn) method, which transitions by mapping the current state to the next.
  • A {WorkflowName}State trait (e.g. MyWorkflowState) that should be implemented for each state.

Transitions

[Transition<R>] is an alias for ControlFlow<R, Box<dyn WorkflowState<R>>>. Each state's next method returns one of:

  • self.transition(next_state) continues to another state.
  • self.transition_with(|state| next_state) continues to another state by mapping the previous state to the new state.
  • self.finish(result) terminates with a result value.
  • self.finish_with(|workflow| result) terminates by mapping the whole workflow to a result.

These map to ControlFlow::Continue for the transition and transition_with methods, and ControlFlow::Break for the finish and finish_with methods.

Advanced topics

Workflows with generic lifetimes

On occasion, your workflow struct may require a generic lifetime parameter.

#[derive(Workflow)]
#[flowstate(
    result = MyWorkflowResult,
    state_trait = MyWorkflowState,
)]
struct MyWorkflow<'workflow, State> {
    #[state]
    state: State,
    ctx: &'workflow str,
}

Note that the Workflow derive macro only supports one lifetime parameter.

If a workflow has a generic lifetime parameter, the generated MyWorkflowState trait will be generic over the workflow lifetime. As such, state transitons must return a Transition<'workflow, MyWorkflowResult>, instead of the usual StaticTransition<MyWorkflowResult>, as shown below.

#[derive(State)]
struct StateA;

impl<'workflow> MyWorkflowState<'workflow> for MyWorkflow<'workflow, StateA> {
    fn next(self) -> Transition<'workflow, WorkflowResult> {
        // ...
        self.transition(next_state)
    }
}

States with generics

States may have arbitrary generic lifetime, const, and type parameters, with generic bounds and where clauses.

#[derive(State)]
struct StateA<'a, const N: usize, T>(&'a T)
where
    T: AsRef<str>;

impl<'workflow, const N: usize, T> MyWorkflowState<'workflow>
    for MyWorkflow<'workflow, StateA<'workflow, N, T>>
where
    T: AsRef<str>,
{
    fn next(mut self: Box<Self>) -> Transition<'workflow, WorkflowResult> {
        // ...
    }
}

Note that the where clause above is added for illustrative reasons. It is generally advisable to avoid adding trait bounds on a structs generic types. See this Stack Overflow answer for reference.

Check out tests/workflow_with_lifetime_generics.rs for a working example.

Async workflows

Workflows can be made asynchronous by setting is_async (shorthand for is_async = true). Note that this requires the async Cargo feature to be enabled.

#[derive(Workflow)]
#[flowstate(
    is_async,
    result = WorkflowResult,
    state_trait = BasicWorkflowState,
)]
struct BasicWorkflow<State> {
    #[state]
    _state: State,
}

The implementation for workflow states remains largely the same.

#[derive(State)]
struct StateA;

#[async_state]
impl BasicWorkflowState for BasicWorkflow<StateA> {
    async fn next(self: Box<Self>) -> AsyncStaticTransition<WorkflowResult> {
        self.transition(StateB)
    }
}

However, note the following differences from the synchronous implementation:

  1. The impl block has been annotated with the #[async_state] attribute. This is a re-export of the async_trait macro, and is exported by the Flowstate prelude. It is preferred to use flowstate::async_state over async_trait::async_trait directly, in case the implementation is replaced in future versions.
  2. The next function is now async.
  3. The next function returns AsyncStaticTransition instead of StaticTransition.

For an example of an async workflow with a generic lifetime parameter, and generic states, see tests/workflow_with_lifetime_generics_async.rs. The implementation is similar, but requires some Send bounds to be added in key places.