korken89 / smlang-rs

A State Machine Language DSL procedual macro for Rust
Apache License 2.0
193 stars 28 forks source link

Fallible actions support #44

Closed ryan-summers closed 1 month ago

ryan-summers commented 1 year ago

Right now, actions are only used to always return data associated with the new state (or no data).

However, in real-world state machines, an action is often fallible (e.g. needs to return a Result).

Currently, with a fallible action, you have to perform the action in a guard. This is undesirable because:

  1. It is unintuitive for users
  2. It doesn't allow for passing data to the final state

It seems like it may be most beneficial to have actions return a Result<NextStateData, Error> to accomodate this use case.

andresv commented 1 year ago

Just ran into same problem. Your proposed change would be very beneficial.

oblique commented 1 year ago

I had the same issue, but I simulated this by having an intermediate state and an event queue/channel.

A naive example:

use anyhow::{bail, Result};
use rand::RngCore;
use std::collections::VecDeque;

smlang::statemachine! {
    transitions: {
        *Idle + Start / start_something = Starting,
        Starting + StartSucceeded / start_succeeded = Started,
        Starting + StartFailed(anyhow::Error) / start_failed = Idle,
    }
}

struct Context {
    events: VecDeque<Events>,
}

impl Context {
    fn new() -> Self {
        Context {
            events: VecDeque::new(),
        }
    }
}

impl StateMachineContext for Context {
    fn start_something(&mut self) {
        match randomly_fails() {
            Ok(()) => self.events.push_back(Events::StartSucceeded),
            Err(e) => self.events.push_back(Events::StartFailed(e)),
        }
    }

    fn start_succeeded(&mut self) {
        println!("Yay!");
    }

    fn start_failed(&mut self, error: &anyhow::Error) {
        println!("Error: {error}");
    }
}

impl StateMachine<Context> {
    fn process_events(&mut self, ev: Events) -> Result<&States, Error> {
        self.context_mut().events.push_back(ev);

        while let Some(ev) = self.context_mut().events.pop_front() {
            self.process_event(ev)?;
        }

        Ok(self.state())
    }
}

fn randomly_fails() -> Result<()> {
    if rand::thread_rng().next_u32() % 2 == 0 {
        Ok(())
    } else {
        bail!("Oh no!");
    }
}

fn main() {
    let mut sm = StateMachine::new(Context::new());

    // Notice that I use `process_events` instead of `process_event`.
    // I know it's a bad name.
    let state = sm.process_events(Events::Start).unwrap();

    match state {
        States::Idle => println!("new state: Idle"),
        States::Started => println!("new state: Started"),
        _ => unreachable!(),
    }
}
oblique commented 1 year ago

Also in my opinion, if we consider the action to be a transition to the next state and if our action fails at the half of the process, that means we need to restore some of the state back. My example above will force you to implement this correctly. If the action was fallible, most probably mistakes will happen.

oblique commented 1 year ago

Just a recomendation since my PR (#49) affects this:

If you plan to add this feature, one way to provide a way to the user to have owned values, is to pass to the actions a &mut values. With this way, a user can have an Option<T> as state data, and then use .take() to get the owned value.

dkumsh commented 1 month ago

I create a PR for this feature. Could you please review it.