qmuntal/stateless

How to handle State timers or counters?

kylebrandt opened this issue · 4 comments

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:

  • Transition into the pending state sPending when eBad event is received and the current state is sGood.
  • (When in sPending, eGood event would set the state to sGood - basically a reset)

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

  • A certain number of consecutive eBad events have been received
  • A certain amount of time has passed

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.

@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:

  • Three events: [eGood, eBad, eTimeout]
  • Three states: [sGood, sBad, sPending]
  • Two extended states: [badCounter, timer]
  • Two guard conditions: [fewBads, tooManyBads]
  • sPending entry action that starts timer countdown
  • sPending exit action that ends timer countdown
  • Run-to-completion execution model (concurrent events will be FIFO queued)

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
}

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!

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.

Closing since answered <3