qmuntal / stateless

Go library for creating finite state machines
BSD 2-Clause "Simplified" License
942 stars 49 forks source link

How to handle State timers or counters? #15

Closed kylebrandt closed 3 years ago

kylebrandt commented 3 years ago

I am trying to figure out how I might implement something along the lines of "stay in state for X duration" or "stay in state for X consecutive events". I do not have a lot of experience with state machines, so I'm wondering if:

  1. What I am trying to do is no longer a finite state machine (so this library is probably not what I want to use)
  2. It is a FSM, but this library isn't good for this particular task
  3. This library is good, you might do it like …?

Simplified Example:

Events: eGood and eBad. A scheduler is running, sending either a good/bad event at mostly regular intervals. These events are the input to the state machine.

States: sGood, sBad, sPending.

My confusion is around the sPending state. The idea of the pending state is that it is a hold for a time duration where consecutive sBad events have been received, before going into the sBad state and performing some action.

sPending Transitions:

What I am not clear on how a transition from sPending to sBad could be done after either:

Things Considered:

A) If using the count method, I could create Pending1, Pending2, Pending3, etc state constants, but this feels cumbersome and wouldn't work as well with time durations I don't think.

B) I can imagine that on receiving a eBad while in the pending state I could start a timer, or create a counter. However, my concern is that I am creating a piece (the timer) of information that is detached from State and StateMachine objects, but does impact the transition behavior of the state machine - and this will get me into trouble.

qmuntal commented 3 years ago

@kylebrandt your use case can be modelled by a FSM as the states are finite and the transitions are well defined. It can also be easily implemented using stateless in multiple ways.

What you are describing, talking in terms of a [https://en.wikipedia.org/wiki/UML_state_machine](UML state machine), is an state machine with:

And the transition diagram could look like:

image

Going to the implementation, it is always recommended to wrap the state machine in an object that just expose the business logic API, in your case Good() and Bad() so all the finite state machine nuance is hidden to the caller. This wrapper will be also responsible of owning the extended states. It could look like:

const (
  maxBads          = 3
  countdownDuration = 3 * time.Second
)

type SM struct {
  fsm      *stateless.StateMachine
  badCount int
  cancelTimer chan struct{}
}

func (sm *SM) Bad() {
  sm.badCount++
  sm.fsm.Fire(eBad)
}

func (sm *SM) Good() {
  sm.badCount = 0
  sm.fsm.Fire(eGood)
}

func (sm *SM) initCountdown(_ context.Context, _ ...interface{}) error {
  go func() {
    select {
      case <-time.After(countdownDuration):
        sm.fsm.Fire(eTimeOut)
      case <-sm.cancelTimer:
    }
  }()
  return nil
}

func (sm *SM) stopCountdown(_ context.Context, _ ...interface{}) error {
  select {
    case sm.cancelTimer <- struct{}{}:
    default:
  }
  return nil
}

func (sm *SM) fewBads(_ context.Context, _ ...interface{}) bool {
  return sm.badCount <= maxBads
}

func (sm *SM) tooManyBads(_ context.Context, arg_s ...interface{}) bool {
  return !sm.fewBads(nil, nil)
}

Finally the configuration of the state machine would happen inside a the NewStateMachine function:

const (
  eGood = "GoodEvent"
  eBad = "BadEvent"
  eTimeOut = "TimeOutEvent"
  sGood = "Good"
  sBad = "Bad"
  sPending = "Pending"
)

func NewStateMachine() *SM {
  fsm := stateless.NewStateMachine(sGood)
  sm := &SM{
    fsm: fsm,
    cancelTimer: make(chan struct{}),
  }
  fsm.Configure(sGood).
    Ignore(eGood).
    Permit(eBad, sPending)

  fsm.Configure(sBad).
    Ignore(eBad).
    Permit(eGood, sGood)

  fsm.Configure(sPending).
    OnEntry(sm.initCountdown).
    OnExit(sm.stopCountdown).
    Permit(eGood, sGood).
    Permit(eTimeOut, sBad).
    Permit(eBad, sBad, sm.tooManyBads).
    Ignore(eBad, sm.fewBads)

  return sm
}
kylebrandt commented 3 years ago

Thank you very much! This is very helpful. I had made this transition table in a doc for myself yesterday:

image

So this really helps me understand how to map that (and I do have some more features in mind to explore that only add complexity, so this is very much appreciated!

qmuntal commented 3 years ago

Always glad to help!

One more comment: If you need to trigger some action when the next state is the current state you can change the Ignore configuration with InternalTransition, which allows for custom actions without calling entry/exit actions, or even PermitReentry to trigger entry/exit actions on every reentry.

kylebrandt commented 3 years ago

Closing since answered <3