Finitomata FTW
· 3 min read
It’s been a year since I started to implement my own FSM library in Elixir . Now it’s funny to read my writings from the time when I had only a gut feeling I’m doing it right.
Now I have a proof that my premonitions were correct.
When I shared my approach to keeping the local cache of some external incoming value as a bunch of
Finitomata
instances to my boss, he replied with the following.
The simple value as an FSM? Have you gone mad man?
No, I haven’t. And here is why.
We do receive the external values via some kind of stream, where we might receive a full snapshot for the key, a delta against the previously received snapshot for that key, and a delta against the snapshot for another key. The flow might contain tens thousands values per a second and the total number of keys might be up to one hundred thousand. This all might need more sophisticated infrastructure than a bare
DynamicSupervisor
or even a
PartitionSupervisor
. We need a handy lookup, and we might later decide to scale it horizontally.
Infinitomata
runs transparently on the cluster, which means all one needs to use more memory/cores to process state transitions, would be adding new nodes to the cluster.
Infinitomata
will use them transparently upon addition.
But what’s wrong with leveraging a standard
:pg
, would probably ask a careful reader. After all,
Infinitomata
is built on top of
:pg
and we unlikely would need any
FSM
goodness to carry simple values. Negative. We definitely might rely on conditionals within values updates, but it would quickly become clumsy making it hard to reason about. On the contrary, we might keep the current state of the value in the
FSM
state, abstracting it out of the business logic. We won’t need to validate anything, because transitions are either possible (allowed,) or ignored.
As I said, we might receive a delta, which implies to get to the actual value, the snapshot must be already there. Yes, we might do somewhat along
value =
if state.snapshot?, do: apply_delta(), else: :ignore
or even pattern-match in the function clause, but it’d be cumbersome, because deltas might rely on the snapshots for other keys. Instead, we might handle the transition for “local” delta if and only we already have a snapshot, meaning we are in the state
ready
. The transition handler for deltas relying on “external” keys would be as easy as
def on_transition(current_state, :foreign_delta, {foreign_key, delta_values}, state) do
target_state =
case Infinitomata.state(key) do
{:error, :not_started} -> Logger.info("…")
%{state: :idle} -> Logger.warn("…")
%{state: :ready} -> apply_delta(delta_values)
end
if target_state == :ok,
do: {:ok, current_state, state},
else: {:ok, target_state, state}
end
This is clean, applies to
:foreign_delta
event only, and leaves the
FSM
in the correct state no matter what. We do gain the sequential processing for free as well. Also, there is no chance one forgets about some combination of state and event and leaves it unhandled. The
:finitomata
compiler would tell the
FSM
is inconsistent, if there are orphans or unhandled transitions.
All the
on_transition/4
callbacks might be as a matter of fact implemented as a
with/1
chain, logging the error, if any, and leaving the
FSM
in the state it was before the event came. There is no chance to end up with some inconsistency.
Even if the FSM definitions is as simple as
@fsm """
idle --> |snapshot| ready
ready --> |snapshot| ready
ready --> |delta| ready
ready --> |foreign_delta| ready
ready --> |shutdown| down
"""
use Finitomata, fsm: @fsm, auto_terminate: true
I prefer to use
FSM
because it abstracts a lot of boilerplate out and makes me to implement business logic only, with all the guarantees and validations, provided by
Finitomata
and
all the necessary
on_transition/4
handlers I am to implement, ready to pattern-match on meaningful states and events.
With a sleek learning curve, first-class test support with
Finitomata.ExUnit
, and plain text FSM declaration as both code and documentation, it’S a pleasure to deal with.
Happy infinite automating!