Clicky

Akka Notes - Finite State Machines - 1

I recently had the opportunity to play with Akka FSM at work for some really interesting use-case. The API (in fact, the DSL) is pretty awesome and the entire experience was amazing. Here's my attempt to log my notes on building a Finite State Machine using Akka FSM. As an example, we'll walk through the steps of building an (limited) Coffee vending machine.

Why not become and unbecome?

We know that the plain vanilla Akka Actors can switch its behavior by using become/unbecome. Then, why do we need Akka FSM? Can't a plain Actor just switch between the States and behave differently? Yes, it could. But while Akka's become and unbecome is most often enough to switch the behavior of Actors with a couple of states involved, building a State Machine with more than a few states quickly makes the code hard to reason with (and even harder to debug).

Not surprisingly, the popular recommendation is to switch to Akka FSM if there are more than 2 states in our Actor.

What's Akka FSM

To expand on it further, Akka FSM is Akka's approach to building Finite State Machines that simplifies management of behavior of an Actor in various states and transitions between those states.

Under the hood, Akka FSM is just a trait that extends Actor.

  trait FSM[S, D] extends Actor with Listeners with ActorLogging

What this FSM trait provides is pure magic - it provides a DSL that wraps a regular Actor enabling us to focus on building the state machine that we have in hand, faster.

In other words, our regular Actor has just one receive function and the FSM trait wraps a sophisticated implementation of the receive method which delegates calls to the block of code that handles the data in a particular state.

One other good thing I personally noticed is that after writing, the complete FSM Actor still looks clean and easily readable.


Alright, let's get to the code. Like I said, we will be building a Coffee Vending Machine using Akka FSM. The State Machine looks like this :

Coffee Machine FSM

State and Data

With any FSM, there are two things involved at any moment in a FSM - the State of the machine at any point in time and the Data that is shared among the states. In Akka FSM, in order to check which is our Data and which are the States, all we need to do is to check its declaration.

class CoffeeMachine extends FSM[MachineState, MachineData] 

This simply means that all the States of the FSM extend from the MachineState and the data that is shared between these various States is just MachineData.

As a matter of style, just like with normal Actor where we declare all our messages in a companion object, we declare our States and Data in a companion object:

object CoffeeMachine {

  sealed trait MachineState
  case object Open extends MachineState
  case object ReadyToBuy extends MachineState
  case object PoweredOff extends MachineState

  case class MachineData(currentTxTotal: Int, costOfCoffee: Int, coffeesLeft: Int)

}

So, as we captured in the State Machine diagram, we have three States - Open, ReadyToBuy and PoweredOff. Our data, the MachineData holds (in reverse order) the numbers of coffees that the vending machine could dispense before it shuts itself down (coffeesLeft), the price of each cup of coffee (costOfCoffee) and finally, the amount deposited by the vending machine user (currentTxTotal) - if it is less than the cost of the coffee, the machine doesn't dispense coffee, if it is more, then we ought to give back the balance cash.

That's it. We are done with the States and the Data.

Before we go through the implementation of each of the States that the vending machine can be in and the various interactions that the user can have with the machine at a particular State, we'll have a 50,000 feet view of the FSM Actor itself.

Structure of FSM Actor

The structure of the FSM Actor looks very similar to the State Machine diagram itself and it looks like this :

class CoffeeMachine extends FSM[MachineState, MachineData] {

  //What State and Data must this FSM start with (duh!)
  startWith(Open, MachineData(..))

  //Handlers of State
  when(Open) {
  ...
  ...

  when(ReadyToBuy) {
  ...
  ...

  when(PoweredOff) {
  ...
  ...

  //fallback handler when an Event is unhandled by none of the States.
  whenUnhandled {
  ...
  ...

  //Do we need to do something when there is a State change?
  onTransition {
    case Open -> ReadyToBuy => ...
  ...
  ...
}
What we understand from the structure:

1) We have an initial State (which is Open) and any messages that is being sent to the Machine during Open State is handled in the when(Open) block, ReadyToBuy state is handled in when(ReadyToBuy) block and so on. The messages that I am referring to here are just like regular messages that we tell a plain Actor except that in case of FSMs, the message is wrapped along with the Data as well. The wrapper is called an Event (akka.actor.FSM.Event) and an example would look like Event(deposit: Deposit, MachineData(currentTxTotal, costOfCoffee, coffeesLeft))

From the Akka documentation :

/**
   * All messages sent to the [[akka.actor.FSM]] will be wrapped inside an
   * `Event`, which allows pattern matching to extract both state and data.
   */
  case class Event[D](event: Any, stateData: D) extends NoSerializationVerificationNeeded

2) We also notice that the when function accepts two mandatory parameters - the first being being the name of the State itself, eg. Open, ReadyToBuy etc and the second argument is a PartialFunction, just like an Actor's receive where we do pattern matching. The most important thing to note here is that each of these pattern matching case blocks must return a State (more on this in next post). So, the code block would look something like

when(Open) {  
    case Event(deposit: Deposit, MachineData(currentTxTotal, costOfCoffee, coffeesLeft)) => {
    ...
    ...

3) Generally, only those messages that match the patterns declared inside the when's second argument gets handled at a particular State. If there is no matching pattern, then the FSM Actor tries to match our message to a pattern declared in the whenUnhandled block. Ideally, all the messages that are common across all the States is coded away in the whenUnhandled. (I am not the one to suggest style but alternatively, you could declare smaller PartialFunctions and compose them using andThen if you would like to reuse pattern matching across selected States)

4) Finally, there is an onTransition function which allows you to react or get notified of changes in States.

Interactions/Messages

There are two kinds of people who interact with this Vending machine - Coffee drinkers, who need coffee and Vendors, who do the machine's administrative tasks.

For sake of organization, I have introduced two traits for all the interactions with the Machine. (Just to refresh, an Interaction/Message is the first element wrapped inside the Event along with the MachineData). In plain old Actor terms, this is equivalent to the message that we send to the Actors.

object CoffeeProtocol {

  trait UserInteraction
  trait VendorInteraction
...
...

VendorInteraction

Let's also declare the various interactions that a Vendor can make with the machine.

  case object ShutDownMachine extends VendorInteraction
  case object StartUpMachine extends VendorInteraction
  case class SetCostOfCoffee(price: Int) extends VendorInteraction
  //Sets Maximum number of coffees that the vending machine could dispense
  case class SetNumberOfCoffee(quantity: Int) extends VendorInteraction
  case object GetNumberOfCoffee extends VendorInteraction

So, the Vendor can

  1. start and shutdown the machine
  2. set the price of the coffee and
  3. set and get the number of coffee remaining in the machine.

UserInteraction

  case class Deposit(value: Int) extends UserInteraction
  case class Balance(value: Int) extends UserInteraction
  case object Cancel extends UserInteraction
  case object BrewCoffee extends UserInteraction
  case object GetCostOfCoffee extends UserInteraction

Now, for the UserInteraction, the User can

  1. deposit money to buy a coffee
  2. get dispensed the extra cash if the deposited money is more than the cost of the coffee
  3. ask the machine to brew coffee if the deposit money is equal or more than the cost of the coffee
  4. cancel the transaction before brewing the coffee and get back all the money deposited
  5. query the machine for the cost of the coffee.

In the next post, we'll go through each of the States and explore on their interactions (along with testcases) in detail.

Code

For the benefit of the impatient, the entire code is available on github.

comments powered by Disqus
Google