Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supporting the ability to have _enter and _exit actions. #35

Open
rkoshy opened this issue Apr 9, 2020 · 7 comments
Open

Supporting the ability to have _enter and _exit actions. #35

rkoshy opened this issue Apr 9, 2020 · 7 comments

Comments

@rkoshy
Copy link

rkoshy commented Apr 9, 2020

There are many state machines where you need to execute an action on entry into that state. This is especially important for more complex state machines where there may be multiple transitions (from different states) that go into a particular target state. In those cases, you would end up having to write the same code in multiple transitions, which makes maintenance more difficult. Instead, an _entry (or other reserved) action would allow the actions to be consolidated to one place. This also applies for when exiting a state.

I've been able to implement it quite easily (not sure if it is completely correct, since I am bypassing the middleware layer).

What are your thoughts?

@krasimir
Copy link
Owner

Interesting. Can you come up with an example.

@illarionvk
Copy link
Contributor

illarionvk commented Apr 10, 2020

This question is related to #16, see comment from Feb 27, 2018 in particular.

Stent produces self-powered state machines, i.e., machines that fully control themselves. In my opinion, adding onEntry hooks would mean relinquishing control.

In comparison, the xstate library provides the onEntry/onExit hooks and condition "guards" to produce statically analyzable state machines, e.g., for generating UML diagrams.

However, the xstate machines behave like oracles — they have the ultimate knowledge, but they do nothing by themselves and require an interpreter.

I think that's the difference between Moore and Mealy machines, but I haven't researched it in detail and might be wrong.

@rkoshy, you can save the action function to a variable and reuse the action:

const finish = function * (machine) {
  return yield { ...machine.state, name: FINAL_TARGET }
}

const config = {
  state: { name: IDLE },
  transitions: {
    [IDLE]: { finish },
    [STAGE_ONE]: { finish },
    [STAGE_TWO]: { finish }
    [FINAL_TARGET]: { reload: { name: IDLE } }
  }
}

@rkoshy
Copy link
Author

rkoshy commented Apr 10, 2020

@illarionvk It's not about saving the action and reusing it. The point is that each state has different actions.

We can create a "technically pure" state machine -- but most of the time state machines are used to control a process. If I need to add complex logic outside of the state machine, which a lesser amount of code in the state machine would be clearer, we need to consider that. XStaet and Machina have done so - but have bloated things to the point where even a simple FSM becomes complex/unwieldy and hard to understand.

I've been using SMC (State Machine Compiler) for many years ... maybe since 2006?? I have complex applications where there are state machine definitions that are thousands of lines long (including sub states/etc). The nicest thing about SMC is that you can, for each transition, describe a set of actions that must be taken (generally implemented in another class), along with guards, and entry/exit conditions. This makes debugging the applications extremely simple since it's clear as to what is happening in the state machine.

@rkoshy
Copy link
Author

rkoshy commented Apr 10, 2020

@krasimir Here you go -- this is a WIP, since I'm porting it over from SMC to stent. Pay particular attention to the Idle, ABConnected and AOnHold. For instance, you can switch back and forth between AOnHold and ABConnected. The _entry allows me to send an event out to the UI so that all relevant components update their visual display in this case. Instead of a pubsub event, it could have been a method call to ui.setCallActive or something like that as well...

const voiceSession = Machine.create('voice-session-fsm', {
      state: {name: 'Idle'},
      sessionId: '',
      transitions: {
        Idle: {
          _entry: (fsm, previousState) => {
            this._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_IDLE, fsm.sessionId);
            fsm.sessionData = undefined;
            fsm.sessionId = undefined;
            fsm.sipCallID = undefined;
            fsm.rtcSessionId = undefined;
          },
          voiceOffered: (fsm, sessionInfo) => {
            fsm.rtcSessionId = sessionInfo.session._id;
            fsm.sipCallID = sessionInfo.request.call_id;
            this.answerVoiceCall();
            this._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_VOICE_STARTED, fsm.sessionId);
            return 'VoiceActive';
          },
          msgProcessSession: (fsm, message) => {
            fsm.callProcessor = `${message.From.parsed._uri._user}@${message.From.parsed._uri._host}`;
            fsm.sessionData = message.body;
            fsm.sessionId = fsm.sessionData.sessionId;
            this._sessionData.parseSessionData(message.body);
            return 'DataActive';
          }
        },
        VoiceActive: {
          msgProcessSession: (fsm, message) => {
            fsm.callProcessor = `${message.From.parsed._uri._user}@${message.From.parsed._uri._host}`;
            fsm.sessionData = message.body;
            fsm.sessionId = fsm.sessionData.sessionId;
            this._sessionData.parseSessionData(message.body);
            return 'VoiceSessionActive';
          }
        },
        DataActive: {
          voiceOffered: (fsm, message) => {
            fsm.rtcSessionId = message.session._id;
            fsm.sipCallID = message.request.call_id;
            this.answerVoiceCall();
            return 'VoiceSessionActive';
          },
        },
        VoiceSessionActive: {
          _entry: (fsm, previousState) => {
            this.sendProcessSessionAck(fsm.callProcessor, fsm.sessionId, 0);
            this._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_ACTIVE, fsm.sessionData);
          },
          msgABConnected: (fsm, message) => {
            return 'ABConnected';
          }
        },
        ABConnected: {
          _entry: (fsm, previousState) => {
            this._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_AB_CONNECTED, fsm.sessionData);
          },
          msgADropped: (fsm, message) => {
            console.log('A Party has dropped off the call');
            return 'Limbo';
          },
          msgWrapupStarted: (fsm, message) => {
            return 'Wrapup';
          },
          cmdEndLine1: (fsm) => {
            this.sendDropA(fsm.callProcessor, fsm.sessionId);
          },
          cmdHoldLine1: (fsm, currentState) => {
            this.sendHoldA(fsm.callProcessor, fsm.sessionId);
          },
          msgAOnHold: (fsm, message) => {
            return 'AOnHold';
          }
        },
        AOnHold: {
          _entry: (fsm, previousState) => {
            this._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_A_HOLD, fsm.sessionData);
          },
          cmdPickupLine1: (fsm, currentState) => {
            this.sendPickupA(fsm.callProcessor, fsm.sessionId);
          },
          msgABConnected: (fsm, message) => {
            return 'ABConnected';
          }
        },
        Limbo: {
          _entry: (fsm, previousState) => {
            console.log('We are in limbo -- hopefully we get a new event');
          },
          // This is when we are connected to the kernel, but we are not in a 'stable' state
          msgWrapupStarted: (fsm, message) => {
            return 'Wrapup';
          },
          msgResetToIdle: (fsm, message) => {
            return 'Idle';
          },
        },
        Wrapup: {
          _entry: (fsm, previousState) => {
            this._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_WRAPUP, fsm.sessionData);
          },
          msgResetToIdle: (fsm, message) => {
            return 'Idle';
          },
          voiceCallEnded: (fsm, message) => {
            console.log(`Voice call has ended for sessionId: ${fsm.sessionId} with SIP CallID: ${fsm.sipCallID}`);
          }
        }
      }
    });

@illarionvk
Copy link
Contributor

@rkoshy, thank you for a detailed example and a comprehensive pull-request. Reserved entry/exit actions might be useful in many scenarios.

Personally, I would create a custom middleware or use the connect helper. I would even prefer the connect helper because it would allow me to store the side-effects code alongside the UI code and disconnect it if the relevant UI components are unmounted.

import { connect } from 'stent/lib/helpers';

// It was unclear what `this` references, so I replaced it with context
const context = {
  ...Context,
  answerVoiceCall() {},
  _pubsub: {
    publishEvent() {}
  }
};

const MACHINE_NAME = "voice-session-fsm";

const initialState = {
  name: "Idle",
  callProcessor: null,
  rtcSessionId: null,
  sessionData: null,
  sessionId: null,
  sipCallID: null
};

// Initializing UI events and side-effects
const onEntry = function(fsm) {
  const { name, callProcessor, sessionData, sessionId } = fsm.state;

  if (name === "Idle") {
    context._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_IDLE, sessionId);
  }

  if (name === "VoiceSessionActive") {
    context.sendProcessSessionAck(callProcessor, sessionId, 0);
    context._pubsub.publishEvent(
      ADEvents.EVENT_VOICE_SESSION_ACTIVE,
      sessionData
    );
  }

  if (name === "ABConnected") {
    context._pubsub.publishEvent(
      ADEvents.EVENT_VOICE_SESSION_AB_CONNECTED,
      sessionData
    );
  }

  if (name === "AOnHold") {
    context._pubsub.publishEvent(
      ADEvents.EVENT_VOICE_SESSION_A_HOLD,
      sessionData
    );
  }

  if (name === "Limbo") {
    console.log("We are in limbo -- hopefully we get a new event");
  }

  if (name === "Wrapup") {
    context._pubsub.publishEvent(
      ADEvents.EVENT_VOICE_SESSION_WRAPUP,
      sessionData
    );
  }
};

// Solution 1
Machine.addMiddleware({
  onStateChanged() {
    const fsm = this
    if (fsm.name === MACHINE_NAME) {
      onEntry(fsm);
    }
  }
});

// Solution 2
const disconnect = connect()
  .with(MACHINE_NAME)
  .map(onEntry);

// Initializing machine
const voiceSession = Machine.create(MACHINE_NAME, {
  state: initialState,
  transitions: {
    Idle: {
      voiceOffered: (fsm, sessionInfo) => {
        context.answerVoiceCall();
        context._pubsub.publishEvent(
          ADEvents.EVENT_VOICE_SESSION_VOICE_STARTED,
          fsm.state.sessionId
        );

        return {
          ...fsm.state,
          name: "VoiceActive",
          rtcSessionId: sessionInfo.session._id,
          sipCallID: sessionInfo.request.call_id
        };
      },
      msgProcessSession: (fsm, message) => {
        const sessionData = context._sessionData.parseSessionData(message.body);

        return {
          ...fsm.state,
          name: "DataActive",
          callProcessor: `${message.From.parsed._uri._user}@${message.From.parsed._uri._host}`,
          sessionData: sessionData,
          sessionId: sessionData.sessionId
        };
      }
    },
    VoiceActive: {
      msgProcessSession: (fsm, message) => {
        const sessionData = context._sessionData.parseSessionData(message.body);

        return {
          ...fsm.state,
          name: "VoiceSessionActive",
          callProcessor: `${message.From.parsed._uri._user}@${message.From.parsed._uri._host}`,
          sessionData: sessionData,
          sessionId: sessionData.sessionId
        };
      }
    },
    DataActive: {
      voiceOffered: (fsm, message) => {
        context.answerVoiceCall();

        return {
          ...fsm.state,
          name: "VoiceSessionActive",
          rtcSessionId: message.session._id,
          sipCallID: message.request.call_id
        };
      }
    },
    VoiceSessionActive: {
      msgABConnected: (fsm, message) => {
        return { ...fsm.state, name: "ABConnected" };
      }
    },
    ABConnected: {
      cmdEndLine1: function(fsm) {
        context.sendDropA(fsm.callProcessor, fsm.sessionId);
      },
      cmdHoldLine1: function(fsm) {
        context.sendHoldA(fsm.callProcessor, fsm.sessionId);
      },
      msgADropped: (fsm, message) => {
        console.log("A Party has dropped off the call");
        return { ...fsm.state, name: "Limbo" };
      },
      msgWrapupStarted: (fsm, message) => {
        return { ...fsm.state, name: "Wrapup" };
      },
      msgAOnHold: (fsm, message) => {
        return { ...fsm.state, name: "AOnHold" };
      }
    },
    AOnHold: {
      cmdPickupLine1: function(fsm) {
        context.sendPickupA(fsm.callProcessor, fsm.sessionId);
      },
      msgABConnected: (fsm, message) => {
        return { ...fsm.state, name: "ABConnected" };
      }
    },
    Limbo: {
      // This is when we are connected to the kernel, but we are not in a 'stable' state
      msgWrapupStarted: (fsm, message) => {
        return { ...fsm.state, name: "Wrapup" };
      },
      msgResetToIdle: (fsm, message) => {
        return { ...fsm.state, name: "Idle" };
      }
    },
    Wrapup: {
      msgResetToIdle: (fsm, message) => {
        return { ...fsm.state, name: "Idle" };
      },
      voiceCallEnded: function(fsm, message) {
        console.log(
          `Voice call has ended for sessionId: ${fsm.sessionId} with SIP CallID: ${fsm.sipCallID}`
        );
      }
    }
  }
});

@rkoshy
Copy link
Author

rkoshy commented Apr 22, 2020

@illarionvk I don't disagree that your method will provide the same end result. However IMHO, from the perspective of readability and understandability, having the entry/exit in the state machine makes it much more obvious that "things" are happening on exit/entry

I go through many hoops to actually send all activity by the user or by the system into the state-machine so that the behavior of the app is completely under the control of the state machine. So then, if I introduce this "side-bar" type behavior where someone is "watching the state machine" and making further changes to the state of the app, UI, etc... I think it's counter-intuitive and defeats the goal of the state-machine being the "controller" of state.

As for UI -- I'm using a completely decoupled eventing model to ensure that the state-machine is not dependent on the UI or vice-versa - I'm actually converting some C++ code that I wrote about 15 years ago. So while the app is in Angular, all the components subscribe to events that tell them about the state of affairs, and they render themselves or remove themselves accordingly.

For instance, if you compare (after removing other transitions, for comparison here)

        Wrapup: {
          _entry: (fsm, previousState) => {
            this._pubsub.publishEvent(ADEvents.EVENT_VOICE_SESSION_WRAPUP, fsm.sessionData);
          },
          ....

to

    Wrapup: {
           ....
    }

the former makes it clear that something will happen immediately on entry into Wrapup.

@krasimir
Copy link
Owner

Very good discussion. I have the feeling that adding @enter and @exit will be a good addition to the library. I'm not sure about the API and how the machine will handle/expose those new methods. Will think about it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants