There is More To Akka-typed Than Meets the Eye

Recently, I took an online course on reactive programming with Scala and Akka. I had already been using akka for a while, but not the akka-typed module, so I was especially curious about it. And it struck me.

The theory

I was expecting a typed actor to be just like a classic akka actor with a type parameter that says what type of message it accepts, and they obviously added this type parameter to the ActorRef. But wait a minute, because as I later found out, there are a couple of perks that come with the upgrade.

The big conceptual change introduced by akka-typed is the Behavior[T] type. In akka-typed, in order to get an actor up and running, one must first define a behavior, and then spawn one or many actors that actually execute the defined behavior. A Behavior[T] can be seen roughly as a function that receives a parameter of type T (the message), reacts to the message, and returns another Behavior[T]. This is an elegant separation between functionality and execution context, and it automatically brings in a few benefits. Below I will list my three favorite ones.

Testing

The behavior can now be tested alone, independently from the execution context. For example, one can take a behavior and, without spawning an actor, “run” a message with that behavior. It seems more functional to me. Like taking a function and applying it to some parameters, abstracting away the context in which it’s executed. Not having to deal with the real-world problems of asynchronous communication makes testing more deterministic.

Error handling

Supervision was improved. When an actor fails, instead of it having to send a message to its parent and wait for a response (possibly over the network), the error handling is done locally. How? By defining the supervision strategy as a behavior wrapper. Behaviors are pretty much like functions, so a behavior can be wrapped like any function, catching any exceptions and handling them. The result is a new behavior like the original one, plus the error handling code. Once a child actor is spawned with this complete behavior, it will know what to do in case of failures. This is an elegant solution, because the wrapping can still be defined in the parent actor code, which decouples both the logic, by keeping the behavior function separated from the error handling, and the execution, by not having to send messages back and forth to know what to do about an error. Remember that regular messages can be used as part of the protocol if needed.

Protocol definition

Another big difference with akka classic, is the introduction of a protocol in the communication between actors. Because an actor’s reference ActorRef[T] is now parameterized, when it receives a message, there is no way to implicitly get the sender’s reference (it could be any type of actor who sent the message). This forces the sender to explicitly pass a reference in a replyTo field in the message sent, making the basis for the communication protocol. I find this concept interesting, because not only the type of messages that an actor expects can be declared in advance, but also the order in which the actor expects them. I will dive deeper into this in the following example.

The project

After finishing the course, eager to try akka-typed myself, I set out to create a pet project to try and see how far I could go in defining a compiler-checked protocol. I usually like to create projects related to music, and this one was no exception. So I modeled a tiny part of a music player: the play queue. I was mostly interested in the states it goes through, and which actions were valid in each state. For example, I can skip a track when I’m playing the first of a long queue of tracks, but I shouldn’t be able to skip ahead when I’m listening to the last queued track. Same as I shouldn’t be able to skip back when I’m playing the first track in the queue. The states and their transitions can be seen in the following diagram.

akka-typed graph
FSM representing the states and transitions of a player’s queue. States suffix “Track” was removed for simplicity, for example “FirstTrack” is shown here as just “First”.

This is a finite state machine (FSM) where each state represents the state of the player’s queue, and the arrows represent the possible messages that can be received along with the corresponding state transition. When the player is initialized, there are no tracks in the queue, so I called it Empty. Then I can add a track to the queue, and it will only have that one track, therefore I called this state OnlyTrack. Adding a second track or more, will move the FSM to the FirstTrack state, meaning it’s playing the first track but there are more tracks ahead. From this state, I am now able to skip the track, moving to MiddleTrack state if there are still more tracks remaining, or to LastTrack if there are only two tracks in the queue.

As mentioned before, using the correct types for the messages sent between the actors and the replyTo field of the responses, I create a protocol that gives me type safety not only in the type of messages exchanged but also in the order I use them.

Here are the messages accepted by the player (a.k.a. commands) and the corresponding responses. Note how for example EnqueueFirstTrack has a replyTo field which is a reference to an actor that expects FirstTrackEnqueued response, which in turn has a reference to an actor which accepts OnlyTrackCommand. Following this chain of messages and replyTo’s is how the protocol is defined.

object PlayerCommands {

  sealed trait PlayerCommand
  sealed trait MiddleTrackCommand extends PlayerCommand
  sealed trait EmptyCommand extends PlayerCommand
  sealed trait FirstTrackCommand extends PlayerCommand
  sealed trait LastTrackCommand extends PlayerCommand
  sealed trait OnlyTrackCommand extends PlayerCommand

  case class Skip(replyTo: ActorRef[SkippedReply])
    extends MiddleTrackCommand
    with FirstTrackCommand

  case class SkipBack(replyTo: ActorRef[SkippedBackReply])
    extends MiddleTrackCommand
    with LastTrackCommand

  case class EnqueueFirstTrack(track: Track, replyTo: ActorRef[FirstTrackEnqueued])
    extends EmptyCommand

  case class EnqueueSecondTrack(track: Track, replyTo: ActorRef[SecondTrackEnqueued])
    extends OnlyTrackCommand

  case class EnqueueTrack(track: Track, replyTo: ActorRef[TrackEnqueuedReply])
    extends MiddleTrackCommand
    with FirstTrackCommand
    with LastTrackCommand

  case class Stop(replyTo: ActorRef[Stopped])
    extends MiddleTrackCommand
    with OnlyTrackCommand
    with FirstTrackCommand
    with LastTrackCommand
}
object PlayerReplies {

  sealed trait Reply {
    def state: Player
  }

  sealed trait SkippedReply extends Reply
  case class SkippedFromFirst(
      state: Player, replyTo: ActorRef[MiddleTrackCommand]) extends SkippedReply
  case class Skipped(state: Player) extends SkippedReply
  case class SkippedToLastTrack(
      state: Player, replyTo: ActorRef[LastTrackCommand]) extends SkippedReply

  sealed trait SkippedBackReply extends Reply
  case class SkippedBackFromLastTrack(
      state: Player, replyTo: ActorRef[MiddleTrackCommand]) extends SkippedBackReply
  case class SkippedBack(state: Player) extends SkippedBackReply
  case class SkippedBackToFirst(
      state: Player, replyTo: ActorRef[FirstTrackCommand]) extends SkippedBackReply

  case class FirstTrackEnqueued(
      state: Player, replyTo: ActorRef[OnlyTrackCommand]) extends Reply
  case class SecondTrackEnqueued(
      state: Player, replyTo: ActorRef[FirstTrackCommand]) extends Reply
  sealed trait TrackEnqueuedReply extends Reply
  case class TrackEnqueuedAfterLast(
      state: Player, replyTo: ActorRef[MiddleTrackCommand]) extends TrackEnqueuedReply
  case class TrackEnqueued(state: Player) extends TrackEnqueuedReply

  case class Stopped(state: Player, replyTo: ActorRef[EmptyCommand]) extends Reply
}

How does this protocol actually enforce the order of the messages? Take a look at this code snippet using the protocol.

object ExampleMain extends App {

  implicit val timeout: Timeout = 3.seconds
  implicit val system: ActorSystem[EmptyCommand] = ActorSystem(PlayerBehaviorFactory.initial(), "example")
  val track = Track("Example")

  val future = for {
    FirstTrackEnqueued(_, only) <- system.ask[FirstTrackEnqueued](EnqueueFirstTrack(track, _))
    SecondTrackEnqueued(_, first) <- only.ask[SecondTrackEnqueued](EnqueueSecondTrack(track, _))
    SkippedToLastTrack(_, last) <- first.ask[SkippedReply](Skip)
    TrackEnqueuedAfterLast(_, middle) <- last.ask[TrackEnqueuedReply](EnqueueTrack(track, _))
    Stopped(_, _) <- middle.ask[Stopped](Stop)
  } yield ()

  future.onComplete(_ => system.terminate())
}

As the example shows, I can’t send an EnqueueSecondTrack message without sending an EnqueueFirstTrack message first, because the initial behavior is an ActorRef[EmptyCommand] which clearly doesn’t accept enqueueing a second track when there’s no track enqueued yet. This is the same pattern that is often used in object-oriented programming, i.e. one method call returns an object that is needed for the next expected method call. Sometimes referred to as the “step builder” pattern.

Now the behavior definition is pretty straightforward. It just reacts to the commands by updating the state (here state means the in-memory state, holding the actual queue data in an instance of a class called Player) and transitioning to the new behavior accordingly. Some replies to the client include a replyTo field, meaning that the state changed and the client should now use the new actor reference for further commands, while others do not include a new actor reference, meaning the client can continue using the same one. Here is the behavior for the FirstTrack state.

private class FirstTrackBehavior(state: Player, ctx: ActorContext[PlayerCommand]) {

  val receive: Behavior[PlayerCommand] = Behaviors.receiveMessagePartial {
    case Skip(replyTo) =>
      val newState = state.skip()
      if (newState.isLastTrack) {
        replyTo ! SkippedToLastTrack(newState, ctx.self)
        PlayerBehaviorFactory.lastTrack(newState)
      } else {
        replyTo ! SkippedFromFirst(newState, ctx.self)
        PlayerBehaviorFactory.middleTrack(newState)
      }

    case EnqueueTrack(track, replyTo) =>
      val newState = state.enqueue(track)
      replyTo ! TrackEnqueued(newState)
      PlayerBehaviorFactory.firstTrack(newState)

    case Stop(replyTo) =>
      val newState = state.stop()
      replyTo ! Stopped(newState, ctx.self)
      PlayerBehaviorFactory.empty(newState)
  }
}

There are a couple of things to discuss about this code.

Firstly, the behavior type Behavior[PlayerCommand]. Does that mean that FirstTrackBehavior, which is meant to only handle FirstTrackCommand’s, is in reality accepting any command, including for example SkipBack? Well, technically yes, and that’s the reason for the receiveMessagePartial. But don’t despair. Remember that the client will only see the behavior type through the lenses of an ActorRef[FirstTrackCommand] included in the replyTo response that it got by sending an EnqueueFirstTrack command to a previous ActorRef[EmptyCommand].

And secondly, the ctx.self sent back in the response to the client. This is related to my previous point. From this point of view ctx.self is of type ActorRef[PlayerCommand]. But take for instance the replyTo field in SkippedToLastTrack, it’s of type ActorRef[LastTrackCommand]. This trick effectively narrows the view for the client regarding the commands that it will be able to send to the player.

Another approach to this behavior implementation, is having multiple behaviors actually limited to handle the valid commands for the state, and instead of transitioning the same actor to a different behavior, spawning new actors each time the behavior changes. I tried this out, but spawning and stopping actors for each state change seemed not only an overkill but also very counter-intuitive.

This all comes down to a limitation of the language, because one can’t have a variable whose type changes in time, or whose methods can be called a limited number of times. Imagine an ActorRef[FirstTrackCommand] that receives a message and changes to ActorRef[MiddleTrackCommand], wouldn’t that be cool? There is a line of research called Linear logic programming which would be able to support these kinds of tricks. I recommend watching this talk if you’re interested.

functional effects

Conclusion

Using akka-typed I was able to define a protocol where the type and order of messages are declared in advance. However, this can probably get too complex quickly when more business rules are introduced. So, as usual, the tradeoffs between type safety and complexity must be analyzed before jumping into implementation.

The complete implementation of my pet project can be found here. In addition to the protocol and behavior implementations, there’s a player_interface package. The idea is that in a real-world scenario, I don’t know in compile-time if the commands are received in the correct order. For instance, I could expose this to a user from a UI, or through http REST endpoints. So this is just a layer that translates those commands into internal protocol commands, or returns the corresponding errors if their order is incorrect.

What started as taking an online course just to be updated with the latest on a framework I like, turned out to be a lot of food for thought. And, ultimately, a good exercise regarding the best software development practices like separation of concerns, functional programming, and the practical limits of verifying the correctness of our software in compile time

Originally published at https://medium.com/@quiquerodrguez/there-is-more-to-akka-typed-than-meets-the-eye-4b8f66ba45b1 on Jun 22, 2020.

Read also

Author

Enrique Rodríguez

I'm a passionate software engineer with experience delivering quality back-end software for a variety of companies. As an enthusiastic learner; I enjoy researching different technologies and architectures, finding the right tool for the job, and putting it into practice. Most recently, I have been focusing on Scala with Akka technologies, Blockchain, and ES/CQRS architectures. In my free time, you can find me learning music, playing the piano, or going for a run at the beach.

Latest Blogposts

15.06.2021 / By Tomasz Bogus

Why do Bitcoins have value?

What makes us pay $50,000 for one unit of cryptocurrency? Yet another day, yet another discussion about current cryptocurrencies’ prices in media and yet another headshake: “how is it possible that people choose to pay that much for something that is entirely digital and so volatile”? It is obvious that despite all the skepticism, cryptocurrencies […]

07.06.2021 / By Daria Karasek

5 Reasons Why Rust Is The Future

In the Stack overflow 2020 survey, Rust was picked as #1 most loved programming language, thanks to 86% of developers who said they would continue using it. For the language creators, this is nothing new – Rust has been winning the survey ever since 2016. On Tiobe Index, Rust is rising in popularity as well […]

01.06.2021 / By Tomasz Bogus

How does Bitcoin work?

A beginner’s guide to bitcoin cryptocurrency If you’ve been asking yourself a few times now: “how does Bitcoin work?” you’re in the right place. Bitcoin has always been present in the media – both traditional and social ones – but recent significant increases in its price, as well as the much-discussed public debut of Coinbase […]

Need a successful project?

Estimate project