appccelerate / statemachine

A .net library that lets you build state machines (hierarchical, async with fluent definition syntax and reporting capabilities).
Apache License 2.0
481 stars 128 forks source link

Stateless operation? #19

Open jsobell opened 6 years ago

jsobell commented 6 years ago

I'm experiencing issues with the current implementation related to the storage of internal state: The state machine should ideally be stateless, as the only state present within a state machine is the state itself, so it makes sense that the engine be assignable the current state, then an event fired, and the resultant state read back out. The IStateMachineSaver almost extracts that functionality, but is a bit messy, and would probably be better implemented as an external property accessor so the machine can acquire the current state itself when an event is fired, and obviously update the resultant state through the setter when a transition has occurred. In situations where you might have many FSMs representing a collection of 'users' but all associated with the same FSM graph, you may well want to pass an event to lots of instances synchronously. The system appears to require you recreate the machine for each user, call Load with a newed-up IStateMachineSaver, then call Fire, then call Save to read the resultant state. To check an event for the next user, it appears we have to dispose of that machine, and restart the process from scratch. I'm not sure if this is all related to the 'history' feature, which seems a bit superfluous given that we have logging, but I'd be interested to know if there are any workarounds for these limitations, other than rewriting the functionality in the source code.

As an example of the non-working code to implement a fast call on behalf of multiple instances:

        public string FireEvent(string startstate, string eventname, IFireEventArgs args)
        {
            _fsm.Load(_persistor.SetState(startstate ?? "START"));
            _fsm.Fire(eventname, args.Arguments);
            _fsm.Save(_persistor);
            return _persistor.State;
        }

Here you can see the messy use of the persistor, which of course doesn't work because Load can't be called twice. A shared FSM is a pretty common requirement, so am I missing something here?

jsobell commented 6 years ago

Hmm, given that the current implementation has associated a queue with an instance, it doesn't look like it's feasible to use the model in this way. It also looks like https://github.com/appccelerate/statemachine/issues/6 is the only realistic workaround for now. This means removing all of the definition from the StateMachine so the definitions are referenced from the StateMachine rather then encapsulated inside it, so in this way they can be re-used on another instance. I would hope that this would allow very fast instantiation of a new instance. Calling the state machine 1 million times with events in our test only takes 2.5 seconds (including a guard expression evaluation call), while having to recreate the machine increases this to 7300 seconds.

ursenzler commented 6 years ago

Hi Jason

There is more state in the state machine than just the current state. There is also the knowledge about what child state was last active in a super state (history). And there is also the "state" of the queue of events to process.

Therefore, I do not see a way to make your wish reality.

Extracting the definition from the state machine would probably help. As you stated, the goal is to make instantiating a state machine very fast. Currently it is not because defining a state machine is quite expensive due to validation checks that the definition makes sense.

If you think, that extracting the definition would help you, then I'll find some time to implement this. Or you may make a PR :-)

Happy coding Urs

jsobell commented 6 years ago

Yes, I noticed and mentioned those two aspects of the current system. A key question here though is whether either of those issues have anything to do with the state machine itself. Certainly history is nothing to do with it, and while there may be hierarchical structures in a system, the nature of a FSM means there can only ever be a single state, so this has to be guaranteed unique anyway. Given those facts, there is no reason state would exist at all in the SM, and anything providing that type of feature needs to be kept outside of both the state map (i.e. definition/config). The queue is a more important aspect, and given that an FSM should never lose or skip an event, it should not be in the machine at all unless the implementation is truly transactional and can persist and restore the queue in the event of a failure. This means it too must be implemented externally, because we can't assume how it will be required by any user.

Please don't take this as criticism of the code you've done here. It's very nicely done, and has a number of distinct advantages over things like the 'stateless' library which only supports actions on entry/exit of states, and doesn't even acknowledge that transactions can be required to perform actions :) Today I wrote an alternative core engine, and I'll try and drop you a Gitter message about it to see if it's any use to you.

ursenzler commented 6 years ago

Note that there is already a split between the core machine (in the namespace Machine) and the part taking care of the queues. But you have probably already seen that :-)

Currently, the real state, the definition and the history data is tightly coupled together in the State class. A fact I do not like anyway (nowadays).

So I agree with you, but I probably have to spend some time with the code to see how this could be changed. It's a while since I made changes in the core of the machine.

Thanks for rising this issue. It's the only way to get real improvement into this project.

I'll be on vacation for the next 2 weeks, so I'll be slow responding...

ursenzler commented 6 years ago

Just wanted to let you know that I started to look into this. Unfortunately, the code resists this change (design not really a match for this feature).

I hope I can soon present a working prototype.

ursenzler commented 6 years ago

Just to let you know, I'm working on this, but it results in a really big change for the state machine. So don't count on having a solution any time soon :-(

jsobell commented 6 years ago

No problems. I tried contacting you on Gitter a few months ago, but had no luck. I wrote a simple but very fast FSM implementation a while ago, and I'll paste it below. It's extremely simple, but allows you to define and persist workflows in JSON (it uses ServiceStack, but Newtonsoft would also be fine) and implements Action calls to perform Guards and Actions. I use it with go.js to design workflows on-screen, and ClearScriptV8 to do all my guard and action interpretation.

Execution is almost instantaneous, and I designed it to handle hundreds of thousands of simultaneous users being tracked through smoking cessation processes. It's hopefully to be implemented in China where they have 300 million smokers, so performance is quite important :)

Anyway, feel free to use, ignore, or rework anything if it's of interest, otherwise, feel free to ignore it 👍

jsobell commented 6 years ago
using System;
using System.Collections.Generic;
using System.Linq;
using ServiceStack.Text;

namespace FastFSM
{
    public abstract class FSM<TStateName, TEvent, TEventArgs> where TEvent : IComparable where TStateName : IComparable
    {
        public FSM()
        {
        }

        public FSM(string name, string description, List<State> states)
        {
            Name = name;
            Description = description;
            States = states;
        }

        // [{"Type":"State","Groups":[],"Name":"START","Tag":"","Transitions":[{"Type":"Transition","Actions":"Console.WriteLine(\"Blah!\");\nvar X = 99;\nvar Y = X + 99;\n","Description":"SMS set quit day","DestinationState":"QUIT_DAY","Event":"","Guard":"return (User.Event.EventName==\"SMS\" && User.Event.Data.Arguments==\"QD\")","metadata":{"points":[70.60688424157995,59.280055660151504,136.42305214076973,101.95446142051085,194.94207506334004,152.52045725669217,245.60136903821768,205]}},{"Type":"Transition","Actions":"","Description":"SMS stop","DestinationState":"QUIT","Event":"","metadata":{"points":[10.385456661284017,64.6530904108268,-29.064712586623884,101.64215948222278,-73.15277510043195,130.48388328068634,-126,157.15241466719647]}},{"Type":"Transition","Actions":"","Description":"START to ZZZZ","DestinationState":"ZZZZ","Event":"","Guard":"return User.Event.EventName==\"XX\" && User.Event.Source==\"TIMER\"","metadata":{"points":[76.77736641333044,39.197200719891086,275.83608957757286,43.368258809857835,473.62409822054997,60.89275628991601,670,89.9155844032866]}}]},{"Type":"State","Description":"User set quit day","Groups":[],"Name":"QUIT_DAY","Tag":"","Transitions":[{"Type":"Transition","Actions":"","Description":"Quit => X","DestinationState":"X","Event":"","Guard":"return true;","metadata":{"points":[243,338.66214334184997,187.9233412084239,381.74100804613795,124.25667454175722,422.25615956128945,52,458.9318577555296]}},{"Type":"Transition","Actions":"","Description":"SMS temp stop","DestinationState":"TEMP_STOP","Event":"","Guard":"return User.Data(\"ST\").Number() > 5;","metadata":{"points":[323.6631603776243,355,329.61252100241876,433.79028971571535,327.37442576432346,512.1236230490487,316.99553994411133,590]}}],"metadata":{"loc":"318 280"}},{"Type":"State","Description":"No longer wants to quit smoking.","Groups":[],"Name":"QUIT","Tag":"","Transitions":[],"metadata":{"loc":"-201 195"}},{"Type":"State","Description":"Xxxxx","Groups":[],"Name":"X","Tag":"","Transitions":[{"Type":"Transition","Actions":"","Description":"X => Quit State","DestinationState":"QUIT","Event":"","Guard":"return false;","metadata":{"points":[-79.05574035665609,422,-110.85233501419336,379.45762014483626,-140.71546967203,328.7909534781696,-167.20593801706116,270]}},{"Type":"Transition","Actions":"","Description":"X => Y","DestinationState":"Y","Event":"","Guard":"return User.Data(\"what?\").Boolean();","metadata":{"points":[-98,493.2126631967818,-162.63038379688572,489.948969447981,-224.63038379688572,479.61563611464766,-284,462.55377674236036]}}],"metadata":{"loc":"-23 497"}},{"Type":"State","Description":"temporary stop the work","Groups":[],"Name":"TEMP_STOP","Tag":"","Transitions":[{"Type":"Transition","Actions":"","Description":"TEMP_STOP to Z","DestinationState":"Z","Event":"","Guard":"return User.Event.EventName==\"SMS\"","metadata":{"points":[382,600.3539622462589,450.94460957923036,540.9273511292473,529.2779429125636,484.1611173630134,617,431.24330587997724]}}],"metadata":{"loc":"307 665"}},{"Type":"State","Description":"X should go here","Groups":[],"Name":"Y","Tag":"","Transitions":[],"metadata":{"loc":"-359 441"}},{"Type":"State","Description":"Z","Groups":[],"Name":"Z","Tag":"","Transitions":[{"Type":"Transition","Actions":"","Description":"Z to SLEEP","DestinationState":"SLEEP","Event":"","Guard":"return true","metadata":{"points":[694.1337011379732,461,696.4152141159578,541.195614232741,690.495317475131,620.195614232741,676.4953509369537,698]}},{"Type":"Transition","Actions":"","Description":"User timeout 1","DestinationState":"Z","Event":"","Guard":"return User.Event.EventName==\"TIMER1\"","metadata":{"points":[735.3012701892235,461,756.3012701892235,497.3730669589464,627.6987298107755,497.3730669589464,648.6987298107755,461]}}],"metadata":{"loc":"692 386"}},{"Type":"State","Description":"Sleep state!","Groups":[],"Name":"SLEEP","Tag":"","Transitions":[],"metadata":{"loc":"663 773"}},{"Type":"State","Description":"[new state]","Groups":[],"Name":"ZZZZ","Tag":"","Transitions":[],"metadata":{"loc":"745 101"}}]

        public static FSM<TStateName, TEvent, TEventArgs> Deserialize(string jsonstring)
        {
            JsonSerializer<FSM<TStateName, TEvent, TEventArgs>> serializer =
                new JsonSerializer<FSM<TStateName, TEvent, TEventArgs>>();
            var model = serializer.DeserializeFromString(jsonstring);
            return model;
        }

        public string Name { get; set; }
        public string Description { get; set; }

        public List<State> States
        {
            get => _states.Values.ToList();
            set { _states = value.ToDictionary(s => s.Name, s => s); }
        }

        public TStateName FireEvent(TStateName fromstate, TEvent eventtype, TEventArgs args)
        {
            try
            {
                var currentstate = _states[fromstate];
                if (currentstate == null)
                {
                    UnknownEventHandler?.Invoke(fromstate, eventtype, args);
                    return fromstate;
                }
                var matchedtransition = currentstate?
                    .Transitions
                    .Where(e => e.Event.CompareTo(eventtype)==0)
                    .FirstOrDefault(t =>
                        String.IsNullOrWhiteSpace(t.Guard) || EvaluateGuard(fromstate, t.Guard, eventtype, args));
                if (matchedtransition == null)
                {
                    UnknownEventHandler?.Invoke(fromstate, eventtype, args);
                    return fromstate;
                }
                Transitioning?.Invoke(fromstate, matchedtransition.DestinationState, eventtype, args);
                matchedtransition.Actions.ForEach(actiontext =>
                    ActionHandler?.Invoke(fromstate, matchedtransition.DestinationState, actiontext, eventtype, args));
                return matchedtransition.DestinationState;
            }
            catch (Exception exception)
            {
                UnhandledExceptionHandler?.Invoke(exception, fromstate, eventtype, args);
                return fromstate;
            }
        }

        public Func<TStateName, string, TEvent, TEventArgs, bool> EvaluateGuard; // = (s, a, e, b) => throw new MissingFieldException("No guard handler assigned");

        public Action<TStateName, TStateName, string, TEvent, TEventArgs> ActionHandler; //  = (f, t, a, e, b) => throw new MissingFieldException("No action handler assigned");

        public Action<TStateName, TStateName, TEvent, TEventArgs> Transitioning;

        public Action<TStateName, TEvent, TEventArgs> UnknownEventHandler; //  = (s, a, b) => throw new MissingFieldException("No unknown event handler assigned");

        public Action<Exception, TStateName, TEvent, TEventArgs> UnhandledExceptionHandler; //  = (e, s, ev, a) => throw new ApplicationException("No UnhandledExceptionHandler assigned", e);

        private Dictionary<TStateName, State> _states;

        public class State
        {
            public State(TStateName name, string description, List<Transition> transitions, string tag = null,
                List<string> groups = null)
            {
                Tag = tag;
                Groups = groups;
                Name = name;
                Description = description;
                Transitions = transitions;
            }

            public string Tag { get; set; }
            public List<string> Groups { get; set; }
            public TStateName Name { get; set; }
            public string Description { get; set; }
            public List<Transition> Transitions { get; set; }
        }

        public class Transition
        {
            public Transition(TEvent tevent, string guard, TStateName destinationstate, List<string> actions,
                string description)
            {
                Guard = guard;
                Event = tevent;
                Actions = actions;
                Description = description;
                DestinationState = destinationstate;
            }

            public TEvent Event { get; set; }
            public string Guard { get; set; }
            public TStateName DestinationState { get; set; }
            public List<string> Actions { get; set; }
            public string Description { get; set; }
        }
    }
}
jsobell commented 6 years ago

Here's an example of the workflow editor: image

ursenzler commented 6 years ago

Thanks a lot for sharing.

This is the kind of design I want to get into my state machine, splitting definition, execution, and state.