YASM - Yet Another State Machine

Pronounced "yaz-um."

Install?

$ gem install yasm

Why?

In a state machine, there are states, contexts, and actions. Actions have side-effects, conditional logic, etc. States have various allowable actions. Contexts can support various states. All beg to be defined in classes. The other ruby state machines out there are great. But they all have hashitis. Classes and mixins are the cure.

How?

Let's create a state machine for a vending machine. What does a vending machine do? It lets you input money and make a selection. When you make a selection, it vends the selection. Let's start off with a really simple model of this:

class VendingMachine
  include Yasm::Context

  start :waiting
end

class Waiting; include Yasm::State; end

class Vending; include Yasm::State; end

So far, we've created a context (a thing that has state), given it a start state, and then defined a couple of states (Waiting, Vending).

So, how do we use this vending machine? We'll need to create some actions first:

class InputMoney
  include Yasm::Action
end

class MakeSelection
  include Yasm::Action

  triggers :vending
end

class RetrieveSelection
  include Yasm::Action

  triggers :waiting
end

And now we can run a simulation:

vending_machine = VendingMachine.new

vending_machine.state.value 
  #==> Waiting

vending_machine.do! InputMoney

vending_machine.state.value 
  #==> Waiting

vending_machine.do! MakeSelection

vending_machine.state.value 
  #==> Vending

vending_machine.do! RetrieveSelection

vending_machine.state.value 
  #==> Waiting

There's some problems, though. Our simple state machine is a little too simple; someone could make a selection without inputing any money. We need a way to limit the actions that can be applied to our vending machine based on it's current state. How do we do that? Let's redefine our states, using the actions macro:

class Waiting
  include Yasm::State

  actions :input_money, :make_selection
end

class Vending
  include Yasm::State

  actions :retrieve_selection
end

Now, when the vending machine is in the Waiting state, the only actions we can apply to it are InputMoney and MakeSelection. If we try to apply invalid actions to the context, Yasm will raise an exception.

vending_machine.state.value 
  #==> Waiting

vending_machine.do! RetrieveSelection
  #==> InvalidActionException: We're sorry, but the action `RetrieveSelection` 
       is not possible given the current state `Waiting`.

vending_machine.do! InputMoney

vending_machine.state.value 
  #==> Waiting

Side Effects

How can we take our simulation farther? A real vending machine would verify that when you make a selection, you actually have input enough money to pay for that selection. How can we model this?

For starters, we'll need to add a property to our VendingMachine that lets us keep track of how much money was input. We'll also need to initialize our InputMoney actions with an amount.

class VendingMachine
  include Yasm::Context
  start :waiting

  attr_accessor :amount_input

  def initialize
    @amount_input = 0
  end
end 

class InputMoney
  include Yasm::Action

  def initialize(amount_input)
    @amount_input = amount_input
  end

  def execute
    context.amount_input += @amount_input
  end
end

Notice I defined the execute method on the action. This is the method that gets run whenever an action gets applied to a state container (e.g., vending_machine.do! InputMoney). This is where you create side effects.

Now we can try out adding money into our vending machine:

vending_machine.amount_input
  # ==> 0

vending_machine.do! InputMoney.new(10)

vending_machine.amount_input
  # ==> 10

As for verifying that we have input enough money to pay for the selection we've chosen, we'll need to create an item, then add that to our MakeSelection class:

class SnickersBar
  def self.price; 30; end
end

class MakeSelection
  include Yasm::Action

  def initialize(selection)
    @selection = selection
  end

  def execute
    if context.amount_input >= @selection.price
      trigger Vending
    else
      raise "We're sorry, but you have not input enough money for a #{@selection}"
    end
  end
end

Notice that we called the trigger method inside the execute method instead of calling the triggers macro on the action. This way, we can conditionally move to the next logical state only when our conditions have been met (in this case, that we've input enough money to pay for our selection).

v = VendingMachine.new

v.amount_input 
  #==> 0

v.do! MakeSelection.new(SnickersBar)
  #==> RuntimeError: We're sorry, but you have not input enough money for a SnickersBar

v.do! InputMoney.new(10)

v.do! MakeSelection.new(SnickersBar)
  #==> RuntimeError: We're sorry, but you have not input enough money for a SnickersBar

v.do! InputMoney.new(20)

v.do! MakeSelection.new(SnickersBar)

v.state.value 
  #==> Vending

v.do! RetrieveSelection

v.state.value
  #==> Waiting

End states

Sometimes, a state is final. Like, what if, out of frustration, you threw the vending machine off the top of a 10 story building? It's probably not going to work again after that. You can use the final! macro on a state to denote that this is the end.

class TossOffBuilding
  include Yasm::Action

  triggers :obliterated
end

class Obliterated
  include Yasm::State

  final!
end

vending_machine = VendingMachine.new

vending_machine.do! TossOffBuilding

vending_machine.do! MakeSelection.new(SnickersBar)
#==> Yasm::FinalStateException: We're sorry, but the current state `Obliterated` is final. It does not accept any actions. 

State Timers

When a vending machine vends an item, it takes about 10 seconds for the item to work it's way off the rack and fall to the bottom. We can simulate this by placing a minimum constraint on the Vending state.

class Vending
  include Yasm::State

  minimum 10.seconds
end

Now, when we go into the vending state, we won't be able to retrieve our selection until 10 seconds have passed.

vending_machine.do! MakeSelection.new(SnickersBar)

vending_machine.state.value
  #==> Vending

vending_machine.do! RetrieveSelection
  #==> Yasm::TimeLimitNotYetReached: We're sorry, but the time limit on the state `Vending` has not yet been reached. 

sleep 10

vending_machine.do! RetrieveSelection

vending_machine.state.value
  #==> Waiting

You can also create maximum time limits. For example, suppose we want our vending machine to self destruct, out of frustration, if it goes an entire minute without any action.

class Waiting
  include Yasm::State

  maximum 1.minute, :action => :self_destruct
end

class SelfDestruct
  include Yasm::Action

  triggers :obliterated

  def execute
    puts "KABOOM!"
  end
end

Now, if we create a vending machine, then wait at least a minute, next time we try to do something to it, it will execute the SelfDestruct action.

v = VendingMachine.new

sleep 60

v.do! InputMoney.new(10)
  #==> "KABOOM!"
  #==> Yasm::FinalStateException: We're sorry, but the current state `Obliterated` is final. It does not accept any actions. 

The Lazy Domino Effect

The maximum time limit on a state can cause a domino effect. For example, suppose the start state for your context has a max time limit. And the action that runs when that time limit is reached transitions to a state with another max time limit. And so on. Now suppose you instantiate your context, and wait a reeeeealy long time. Like, long enough to cause a state transition domino effect. Let's model this with a traffic light system:

class TrafficLight
  include Yasm::Context

  start :green
end

class Green
  include Yasm::State

  maximum 10.seconds, :action => :transition_to_yellow
end

class TransitionToYellow
  include Yasm::Action

  triggers :yellow

  def execute
    puts "transitioning to yellow."
  end
end

class Yellow
  include Yasm::State

  maximum 3.seconds, :action => :transition_to_red
end

class TransitionToRed
  include Yasm::Action

  triggers :red

  def execute
    puts "transitioning to red."
  end
end

class Red
  include Yasm::State

  maximum 13.seconds, :action => :transition_to_green
end

class TransitionToGreen
  include Yasm::Action

  triggers :green

  def execute
    puts "transitioning to green."
  end
end

t = TrafficLight.new

puts t.state.value
  #==> Green

sleep 30

t.state.value
  #==> "transitioning to yellow."
  #==> "transitioning to red."
  #==> "transitioning to green."
  #==> Green 

Notice that this domino effect happened lazily when you call the do! method, or the context.state.value methods. Quite nice for systems where you persist your state to a db.

Persistence

How do you persist your state to a database? YASM will automatically persist/load your states to/from the database; it supports (or plans to support) the following ORMs:

  • couchrest_model (as of version 0.0.4)
  • mongoid (coming soon)
  • active_record (coming soon)

For example, let's suppose our vending machine context was actually a CouchDB document, modelled with CouchRest::Model:

class VendingMachine < CouchRest::Model::Base
  include Yasm::Context

  start :waiting
end

#.....

By simply mixing Yasm::Context into the document, our states will be automatically persisted to the database and loaded from the database.

Anonymous and Named States

Up till now, we've been utilizing the anonymous state on our context. In other words, because we didn't wrap our start :waiting inside a state call, YASM assumed that we were simply going to be using the anonymous state on our class. Also, when we've called the do! method, we've called it directly on our context, which again assumes that you're attempting to apply an action to the anonymous state on your context.

What's an example of a named state? Perhaps we'd like to manage the electricity on our vending machine with a state machine. To do so, we'd simply:

class VendingMachine
  include Yasm::Context

  start :waiting

  state(:electricity) do
    start :on
  end
end

class Waiting;  include Yasm::State; end
class Vending;  include Yasm::State; end
class On;       include Yasm::State; end
class Off;      include Yasm::State; end

class Unplug
  include Yasm::Action

  triggers :off
end

class Plugin
  include Yasm::Action

  triggers :on
end

class Select
  include Yasm::Action

  triggers :vending
end

Now our VendingMachine has two managed states: the anonymous state (that start in the Waiting state), and the "electricity" state (that starts in the On state).

We can apply actions to each of these states independently:

v = VendingMachine.new

puts v.state.value 
  #==> Waiting

puts v.electricity.value
  #==> On

v.do! Select

puts v.state.value 
  #==> Vending

v.electricity.do! Unplug

puts v.electricity.value
  #==> Off

Action Callbacks

How do you run a method before or after an action is applied to a yasm state within your context? Yasm::Context gives you the following two callback macros for this purpose: before_action and after_action. They each accept two parameters: a symbol representing the method on the context you'd like called, and an options hash, where you can specify :only or :except constraints.

For example:

class Human
  include Yasm::Context

  start :alive
  before_action :weigh_options, :except => :jump_off_building
  after_action :consider_results, :only => :jump_off_building

  private
  def weigh_options
    puts "You could, alternatively, jump off a building."
  end

  def consider_results
    puts "Splendid!"
  end
end

class Alive; include Yasm::State; end
class Dead;  include Yasm::State; end

class GoToWork
  include Yasm::Action
end

class JumpOffBuilding
  include Yasm::Action
  triggers :dead

  def execute
    puts "Weeeeeee!"
  end
end

Now, when we apply actions to the anonymous state, before and after actions will run when appropriate:

you = Human.new

you.do! GoToWork
  #==> "You could, alternatively, jump off a building."

you.do! JumpOffBuilding
  #==> "Weeeeeee!"
  #==> "Splendid!"

Just as you can use before_action and after_action with the anonymous state, you can use it with named states as well:

class VendingMachine
  include Yasm::Context

  state(:electricity) do
    start :on
    before_action :warn, :only => :unplug
    after_action  :sigh
  end

  private
  def warn
    puts "Wait! Don't unplug me!!!"
  end

  def sigh
    puts "sigh...."
  end
end

class On; include Yasm::State; end
class Off; include Yasm::State; end

class Unplug
  include Yasm::Action
  triggers :off

  def execute
    puts "unplugging...."
  end
end

v = VendingMachine.new

v.electricity.do! Unplug
  #==> "Wait! Don't unplug me!!!"
  #==> "unplugging...."
  #==> "sigh...."

PUBLIC DOMAIN

This software is committed to the public domain. No license. No copyright. DO ANYTHING!