Does this look ok as a Finite State Machine?

Hello. I 'm creating a mix project which builds a finite state machine on redis. While I’m writing code of the repository, I referred a repository finist which is written in ruby.

Implementation

defmodule Dfm do
  @moduledoc """
  Documentation for `Dfm`.
  """

  require Logger

  @script """
  local curr = redis.call("GET", KEYS[1])
  local next = redis.call("HGET", KEYS[2], curr)
  if next then
    redis.call("SET", KEYS[1], next)
    return { next, true }
  else
    return { curr, false }
  end
  """

  @redis_host "localhost"
  @redis_port 6379

  defp conn() do
    with {:ok, conn} <- Redix.start_link(host: @redis_host, port: @redis_port) do
      conn
    else
      error -> raise "Failed to connect to #{@redis_host}:#{@redis_port} #{error}"
    end
  end

  def flushall() do
    conn = conn()
    Redix.command(conn, ["FLUSHALL"])

    Logger.info("Flushed all")
  end

  @doc """
  Initializes state of automaton.
  """
  @spec initialize(String.t(), integer(), String.t()) :: Redix.Protocol.redis_value()
  def initialize(key_name, db_index, initial_state) do
    conn = conn()
    name = name(key_name)

    Redix.command!(conn, ["SELECT", db_index])
    Redix.command!(conn, ["SET", name, initial_state, "NX"])
  end

  @spec name(String.t()) :: String.t()
  defp name(key_name), do: "finite:#{key_name}"

  @spec event_key(String.t(), String.t()) :: String.t()
  defp event_key(key_name, event), do: "#{key_name}:#{event}"

  @doc """
  Defines how automaton changes the state.
  """
  @spec on(String.t(), integer(), String.t(), String.t(), String.t()) :: Redix.Protocol.redis_value()
  def on(key_name, db_index, event, current_state, next_state) do
    conn = conn()

    Redix.command!(conn, ["SELECT", db_index])
    Redix.command!(conn, ["HSET", event_key(key_name, event), current_state, next_state])
  end

  @doc """
  Removes a pattern of state change.
  """
  @spec rm(String.t(), integer(), String.t()) :: Redix.Protocol.redis_value()
  def rm(key_name, db_index, event) do
    conn = conn()

    Redix.command!(conn, ["SELECT", db_index])
    Redix.command!(conn, ["HDEL", event_key(key_name, event)])
  end

  @doc """
  Return current state.
  """
  @spec state(String.t(), integer()) :: Redix.Protocol.redis_value()
  def state(key_name, db_index) do
    conn = conn()

    Redix.command!(conn, ["SELECT", db_index])
    Redix.command!(conn, ["GET", name(key_name)])
  end

  @spec send_event(String.t(), integer(), String.t()) :: Redix.Protocol.redis_value()
  defp send_event(key_name, db_index, event) do
    conn = conn()

    Redix.command!(conn, ["SELECT", db_index])
    Redix.command!(conn, ["EVAL", @script, 2, name(key_name), event_key(key_name, event)])
  end

  @doc """
  Triggers state change.
  """
  @spec trigger(String.t(), integer(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def trigger(key_name, db_index, event) do
    [state, result] = send_event(key_name, db_index, event)
    do_trigger(state, result)
  end

  defp do_trigger(state, nil), do: {:error, state}
  defp do_trigger(state, _), do: {:ok, state}
end

Test

defmodule DfmTest do
  use ExUnit.Case
  doctest Dfm

  describe "dfm" do
    test "simple automaton" do
      Dfm.flushall()

      1..5
      |> Enum.to_list()
      |> Enum.each(fn n ->
        key_name = "user#{n}"
        db_index = 15

        state1 = "a"
        state2 = "b"
        state3 = "c"
        trigger1 = "x"
        trigger2 = "y"
        invalid_trigger = "invalid"

        # NOTE: Define state changes
        Dfm.initialize(key_name, db_index, state1)
        Dfm.on(key_name, db_index, trigger1, state1, state2)
        Dfm.on(key_name, db_index, trigger1, state2, state3)
        Dfm.on(key_name, db_index, trigger1, state3, state1)
        Dfm.on(key_name, db_index, trigger2, state1, state3)

        assert Dfm.state(key_name, db_index) == state1
        assert {:ok, state3} = Dfm.trigger(key_name, db_index, trigger1)
        assert Dfm.state(key_name, db_index) == state2
        assert {:ok, state3} = Dfm.trigger(key_name, db_index, trigger1)
        assert Dfm.state(key_name, db_index) == state3
        assert {:ok, state3} = Dfm.trigger(key_name, db_index, trigger1)
        assert Dfm.state(key_name, db_index) == state1
        assert {:ok, state3} = Dfm.trigger(key_name, db_index, trigger2)
        assert Dfm.state(key_name, db_index) == state3

        # NOTE: Invalid patterns
        assert {:error, state3} = Dfm.trigger(key_name, db_index, trigger2)
        assert {:error, state3} = Dfm.trigger(key_name, db_index, invalid_trigger)
      end)
    end
  end
end

That’s all of this repository. I think it works as a finite state machine properly. But I’m not a skilled engineer, so I’d like to show the codes to everyone. I want advice for the repository!
Thank you.

1 Like

Hi, based on principle do not invent your own wheel, you should implement your finite state machine using this simple library that will guarantee that your machine is a finite state machine:

1 Like

Or dont use any lib at all.

def handle(:state_1, :signal_a) do
  ...
  :new_state
end
1 Like

I’d recommend a vanilla elixir module and struct + functions for the state machine model. Then a GenServer (possibly dynamically supervised & registered) wrapping the FSM struct and persisting to a system of your choice (Redis, Postgres, etc).

1 Like