Skip to content

Finite State Machines

tarcieri edited this page Nov 19, 2014 · 25 revisions

There are many options for describing Finite State Machines (FSM) with Celluloid.

Built-in FSM module

Celluloid provides its own Celluloid::FSM module which is loosely modeled after Erlang's gen_fsm. While relatively sparse on features (which some might consider a good thing), this module is directly integrated with Celluloid's features like timers, making it easy to describe FSMs which automatically transition to another state after a timeout.

Example:

class Connection
  include Celluloid::FSM
  default_state :connected

  state :connected, :to => :disconnected do
    puts "Connected!"
  end

  state :disconnected
end

3rd Party RubyGems

The statemachine gem has been around for several years and is quite mature. Its primary difference from the state_machine gem is the ability to swap "context" references so that you can have different behavior for the same events depending upon the current state.

There is no out-of-the-box support for Celluloid in the statemachine gem and thankfully none is necessary. The statemachine itself should not be an actor. To prove that, let's run a simple example.

How it works without an actor

require 'statemachine'

class VendingMachineContext

  def activate
    puts "activating"
  end

  def release(product)
    puts "releasing product: #{product}"
  end

  def refund
    puts "refunding dollar"
  end

  def sales_mode
    puts "going into sales mode"
  end

  def operation_mode
    puts "going into operation mode"
  end

  def on_dollar
    @statemachine.dollar
  end

  def on_selection(*args)
    @statemachine.selection(*args)
  end
end

class Machine
  class VendingStateMachine < Statemachine::Statemachine
  end

  def self.build
    Statemachine.build(VendingStateMachine.new) do
      state :waiting do
        event :dollar, :paid, :activate
        event :selection, :waiting
        on_entry :sales_mode
        on_exit :operation_mode
      end
      trans :paid, :selection, :waiting, :release
      trans :paid, :dollar, :paid, :refund
      context VendingMachineContext.new
    end
  end
end

vending_machine = Machine.build

vending_machine.dollar
vending_machine.dollar
vending_machine.selection "Peanuts"

# going into sales mode
# going into operation mode
# activating
# refunding dollar
# releasing product: Peanuts
# going into sales mode

As shown, the statemachine transitions through its states properly and executes the appropriate entry, exit and guard actions for each state.

How it misbehaves when the state machine is an actor

Issuing several asynchronous events to the machine results in very peculiar behavior.

require 'statemachine'
require 'celluloid'

class VendingMachineContext

  def activate
    puts "activating"
  end

  def release(product)
    puts "releasing product: #{product}"
  end

  def refund
    puts "refunding dollar"
  end

  def sales_mode
    puts "going into sales mode"
  end

  def operation_mode
    puts "going into operation mode"
  end

  def on_dollar
    @statemachine.dollar
  end

  def on_selection(*args)
    @statemachine.selection(*args)
  end
end

class Machine
  class VendingStateMachine < Statemachine::Statemachine
    include Celluloid
  end

  def self.build
    Statemachine.build(VendingStateMachine.new) do
      state :waiting do
        event :dollar, :paid, :activate
        event :selection, :waiting
        on_entry :sales_mode
        on_exit :operation_mode
      end
      trans :paid, :selection, :waiting, :release
      trans :paid, :dollar, :paid, :refund
      context VendingMachineContext.new
    end
  end
end

vending_machine = Machine.build

vending_machine.async.dollar
vending_machine.async.dollar
vending_machine.async.selection "Peanuts"


# going into sales mode
# going into operation mode
# going into operation mode
# activating
# activating
# D, [2013-02-08T15:11:04.270277 #88091] DEBUG -- : Terminating 5 actors...
# D, [2013-02-08T15:11:04.278727 #88091] DEBUG -- : Shutdown completed cleanly

By default, Celluloid actors run in ATOM mode (see Pipelining and execution modes and Glossary for more information). As a result, a single actor can process multiple messages in parallel. In the example above, the dollar event is sent to the machine which in turn calls the action :activate. Since the machine itself is an actor, the call to :activate results in the machine being suspended (it's a message to an actor) so the second dollar event begins processing. Since the machine contains mutable state (that is, the State that the machine is in can change depending upon the events it has received) then ATOM mode is unsafe.

It's tempting to include the exclusive method in the class definition. This indicates that the entire object should process messages one at a time; it will not process any further messages until the current message has completed. Unfortunately, the statemachine must call into itself to transition between various states, execute actions, etc. As a result, it hangs almost immediately.

The solution is to invert the delivery of events to the state machine.

How it works with actors

require 'statemachine'
require 'celluloid'

class VendingMachineContext
  include Celluloid
  #6
  exclusive

  # 2
  def initialize
    @statemachine = Machine.build
    @statemachine.context = wrapped_object
    @statemachine.initialized
  end

  def activate
    puts "activating"
  end

  def release(product)
    puts "releasing product: #{product}"
  end

  def refund
    puts "refunding dollar"
  end

  def sales_mode
    puts "going into sales mode"
  end

  def operation_mode
    puts "going into operation mode"
  end

  # 4
  def on_dollar
    @statemachine.dollar
  end

  # 5
  def on_selection(*args)
    @statemachine.selection(*args)
  end
end

class Machine
  class VendingStateMachine < Statemachine::Statemachine
  end

  def self.build
    Statemachine.build(VendingStateMachine.new) do
      # 3
      trans :initializing, :initialized, :waiting

      state :waiting do
        event :dollar, :paid, :activate
        event :selection, :waiting
        on_entry :sales_mode
        on_exit :operation_mode
      end
      trans :paid, :selection, :waiting, :release
      trans :paid, :dollar, :paid, :refund
      #1
    end
  end
end

vending_machine = VendingMachineContext.new

vending_machine.async.on_dollar
vending_machine.async.on_dollar
vending_machine.async.on_selection "Peanuts"


# going into sales mode
# going into operation mode
# activating
# refunding dollar
# releasing product: Peanuts
# going into sales mode
# D, [2013-02-08T15:11:04.270277 #88091] DEBUG -- : Terminating 5 actors...
# D, [2013-02-08T15:11:04.278727 #88091] DEBUG -- : Shutdown completed cleanly

Let's step through the changes that were made to make the statemachine safe for use within an actor.

We need to get to a point where all events are being sent to an actor's mailbox so that access is serialized. We also need to allow the statemachine to execute actions and modify its state without getting suspended (thereby allowing a race condition on state mutation) or blocked (by making multiple calls into an actor and deadlocking).

We do this by inverting the relationship between state machine and context that we had in our earlier examples. Instead of instantiating the machine, which in turn instantiates the context, we flip it.

  1. Remove instantiation of the context from the machine creation.

  2. Add an initialize method to the VendingMachineContext class. Give it the responsibility for instantiating the state machine. The change in #2 causes a small problem. When calling Machine.build the machine wants to go into the default state of :waiting. It makes this transition before any context is set, so it blows up when trying to execute the on_entry action of sales_mode. To resolve this, we need to add a new initial state to the machine.

  3. Add an :initializing state. Send the machine the :initialized event in VendingMachineContext#initialize after the context has been assigned. Also, note that the context is assigned the wrapped_object instead of Actor.current. As mentioned above, we want the state machine to be able to transition between states and execute any actions without getting suspended. Therefore, it needs to work on the bare object and not the actor.

  4. Add the on_dollar method as a way to send the dollar event to the machine. As a method on the Actor, these messages will come through the actor's mailbox.

  5. Add the on_selection method. Reasoning is the same as for #4.

  6. As a consequence of exposing the wrapped_object in step 3, we need to make access to the actor exclusive. This is also known as Erlang-mode.