DEV Community

Cover image for Designing state machines in Rust
senyo
senyo

Posted on • Edited on

Designing state machines in Rust

This post was originally posted on my blog


In Rust, you often hear about state machines. Futures are state machines! I thought it would be cool to read more about it. I came across this blog post (funnily enough, by a friend and mentor of mine) which really helped me! I highly recommend reading it. In this post, I'm just noting the part I found relevant. The example here is from her blog post.

P.S.A: Go and read that post 😆

State Machines

Rust is able to represent state machines quite elegantly. Due to its type system, you can make sure invariants are upheld. To use a real life example, we can do some basic modeling of Raft (no knowledge needed for this post). A node in a Raft cluster can be in one of 3 states:

  • Candidate
  • Follower
  • Leader

The possible transitions are:

  • Follower -> Candidate
  • Candidate -> Leader
  • Candidate -> Follower
  • Leader -> Follower

Therefore the only invalid state transition is: Follower -> Leader.

Now we can start modeling this. We have a struct Node that represents a node in the cluster

struct Node<S> {
  ...
  state: S
}
Enter fullscreen mode Exit fullscreen mode

S is a generic parameter over the possible states. Our states are also structs

struct Follower; // In reality, these are likely to hold some data
struct Candidate;
struct Leader;
Enter fullscreen mode Exit fullscreen mode

In Raft, all nodes start in the Follower state. We can define a function new for that state only.

impl Node<Follower> {
  pub fn new() -> Node<Follower> {
    ..
    state: Follower {}
  }
}
Enter fullscreen mode Exit fullscreen mode

We can initialise this like so

let follower = Node::<Follower>::new();
Enter fullscreen mode Exit fullscreen mode

The important thing is that we can't do this for other states! If we do

let leader = Node::<Leader>::new();
Enter fullscreen mode Exit fullscreen mode

we will get an error because we haven't defined the function new for Node<Leader>. This is super sweet!

The next thing we need to be able to do is have state transitions enforceable by the compiler. We can do this using the From trait. If we want to transition from Follower to Candidate we can implement From for it.

impl From<Node<Follower>> for Node<Candidate> {
  fn from(state: Node<Follower>) -> Node<Candidate> {
    let candidate = Node {
      ..
      state: Candidate {}
    }
    candidate
  }
}
Enter fullscreen mode Exit fullscreen mode

We can use it as such

let follower = Node { state: Follower {}}
let candidate = Node::<Candidate>::from(follower);
Enter fullscreen mode Exit fullscreen mode

Now we've gotten the type system on our side! If we don't implement From for Follower -> Leader, doing

let leader = Node::<Leader>::from(follower);
Enter fullscreen mode Exit fullscreen mode

will cause a compilation error. Nice!

Thoughts

On using this design, one issue I came across was implementing general behaviours across all states. Since each impl block is dedicated to a particular state (e.g Node<Follower>, Node<Leader>), you have to duplicate any methods you use across them. We can get around this using our favourite feature: traits! We could have something like

struct Follower;
struct Candidate;
struct Leader;

trait NodeState {}
impl NodeState for Follower {}
impl NodeState for Candidate {}
impl NodeState for Leader {}

impl <S: NodeState> Node<S> {
    // Generic methods
}
Enter fullscreen mode Exit fullscreen mode

We've added the trait bound NodeState. All states S that implement NodeState will have the functionality in the impl block.

Top comments (0)