Skip to content

WideActor

devlaam edited this page Jul 13, 2023 · 9 revisions

WideActor: Open to the world

Text compliant with Leucine version 0.5.x

Introduction

The WideActor is an actor that effectively eliminates the need for typed letters. By effectively we mean that, although the actor is not able to receive an instance of the type Any it is able to directly receive any instance of the type Actor.Letter[Actor]. Since you are always able to derive any letter from this type, it works as the 'Any' among the letters.

Now, to which end is this a good idea? Is it not much saver to have the letters, and where possible the senders as well, typed? Yes it is, but if you are moving from one actor system to an other, it might be good to do so with a running system. Transforming every letter from untyped to typed requires quite some refactoring, which you might not want to do all at once. So step by step is better.

Let's show this, starting from an example.

Ping Pong with Akka.

Suppose we have the following example Ping Ping example in Scala 3. This particular example was based on an example generated by ChatGPT 4.0, but it resembles the ping-pong example by Alvin Alexander a lot, so credit goes to him as well.

import akka.actor.{Actor, ActorSystem, Props}

object Letters :
  case object Ping
  case object Pong
  case object Start
  case object Stop

class PingActor extends Actor :
  def receive: Receive =
    case Letters.Start =>
      println("Ping received Start")
      sender() ! Letters.Ping
      context.become(waitForPong)

  def waitForPong: Receive =
    case Letters.Pong =>
      println("Ping received Pong")
      sender() ! Letters.Ping
      context.become(waitForStop)

  def waitForStop: Receive =
    case Letters.Stop =>
      println("Ping received Stop")
      context.stop(self)


class PongActor extends Actor :
  def receive: Receive =
    case Letters.Ping =>
      println("Pong received Ping")
      sender() ! Letters.Pong
    case Letters.Stop =>
      println("Pong received Stop")
      context.stop(self)


object Main :
  val system = ActorSystem("PingPongSystem")
  val pingActor = system.actorOf(Props[PingActor]())
  val pongActor = system.actorOf(Props[PongActor]())

  def main(args: Array[String]): Unit =
    pingActor.tell(Letters.Start,pongActor)
    Thread.sleep(200)
    pingActor ! Letters.Stop
    pongActor ! Letters.Stop
    Thread.sleep(200)
    system.terminate()

The main method activates the ping pong sequence by sending Start to the PingActor, as if the PongActor did so. PingActor answers PongActor with Ping and changes its state to accept Pong only. After the Pong comes in from the PongActor, the PingActor changes it state again to wait for Stop.

You can see the code in action in Scastie. The result being:

Ping received Start
Pong received Ping
Ping received Pong
Pong received Ping
[INFO] [akkaDeadLetter][05/23/2023 08:56:45.390] [PingPongSystem-akka.actor.default-dispatcher-6] [akka://PingPongSystem/user/$a] Message [Letters$Pong$] from Actor[akka://PingPongSystem/user/$b#1897687362] to Actor[akka://PingPongSystem/user/$a#-965676017] was unhandled. [1] dead letters encountered. This logging can be turned off or adjusted with configuration settings 'akka.log-dead-letters' and 'akka.log-dead-letters-during-shutdown'.
Pong received Stop
Ping received Stop
[INFO] [05/23/2023 08:56:45.807] [run-main-0] [CoordinatedShutdown(akka://PingPongSystem)] Running CoordinatedShutdown with reason [ActorSystemTerminateReason]

Observe that one letter was not handled due to the fact that we changed from waitForPong to waitForStop.

What we want to do now, is to transform this example into something that compiles under Leucine. The transformation is important here, for writing an application with the same goal is probably easier.

Ping Pong with Leucine

The steps we have to take to translate this example to Leucine are:

  1. Derive all letters from Actor.Letter[Actor]
  2. Define companion objects for each actor.
  3. Define states for the different Receive partial functions.
  4. Derive all actors from WideActor

Derive all letters from Actor.Letter[Actor]

This is pretty strait forward, we get:

object Letters :
  case object Ping  extends Actor.Letter[Actor]
  case object Pong  extends Actor.Letter[Actor]
  case object Start extends Actor.Letter[Actor]
  case object Stop  extends Actor.Letter[Actor]

Define companion objects for each actor

For the PongActor this is extremely short, we just need to state that we are stateless. There are no other types to define since the Letters are all defined in one object.

object PongActor extends WideDefine, Stateless

For PingActor this is a little more involved since we utilize the State here. See below.

Define states for the different partial functions.

In Leucine the receive method is an ordinary method and not a partial function. We can use the ProcessAid to distribute the single receive method over self defined partial functions, see the page ProcessAid for the example. But here we want to stick to one receive method. So how do we go about? One solution is to ignore this difference and simply put all the handling in one method for PingActor, just as is done in PongActor. In this case that would work, but we must also be prepared for the general case.

We solve this to define enum's on the state, so we get:

object PingActor extends WideDefine :
  enum State extends Actor.State { case WaitForStart, WaitForPong, WaitForStop }
  def initial = State.WaitForStart

It is also needed to define the first state explicitly by def initial to be State.WaitForStart

Derive all actors from WideActor

For PongActor this is handling the two cases based on the letter, sender alone:

class PongActor extends WideActor(PongActor) :
  def receive(letter: Letter, sender: Sender): Receive = (letter,sender) match
    case (Letters.Ping, source: PingActor) =>
      println("Pong received Ping")
      source ! Letters.Pong
    case (Letters.Stop,_) =>
      println("Pong received Stop")
      stop(Actor.Stop.Direct)

For PingActor we handle each state in combination with the expected message. When received we have a state transition to the new state. Note that an unhandled letter here does not lead to a dead letter, but to a run time error. If you do not want that, the user must explicitly handle the situation with a catch all: case _. Here you see the disadvantage from accepting letters from any actor. The compiler cannot verify that all situations were handled.

class PingActor extends WideActor(PingActor) :
  import PingActor.State.{WaitForStart,WaitForPong,WaitForStop}
  def receive(letter: Letter, sender: Sender): Receive = (state: State) => (letter,sender,state) match
    case (Letters.Start, source: PongActor, WaitForStart) =>
      println("Ping received Start")
      source ! Letters.Ping
      WaitForPong
    case (Letters.Pong, source: PongActor, WaitForPong) =>
      println("Ping received Pong")
      source ! Letters.Ping
      WaitForStop
    case (Letters.Stop, _, WaitForStop) =>
      println("Ping received Stop")
      stop(Actor.Stop.Direct)
      WaitForStart
    case _ =>
      println(s"Unhandled letter: $letter, from $sender, while: $state")
      state

Btw, there also exists a cleaner way of catching unmatched letters, and that is by making use of unmatched(letter,sender). More on that later.

Main method

The main method becomes slightly simpler, with the same functionality. New actors can simply be defined, no further trickery necessary. With send we can send a letter in the name of some other actor. Finally, there is no need to manually terminate the system; when there are no letters to process left, the system terminates itself.

object Main :
  given Actor.Anonymous = Actor.Anonymous
  val pingActor = new PingActor
  val pongActor = new PongActor

  def main(args: Array[String]): Unit =
    pingActor.send(Letters.Start,pongActor)
    Thread.sleep(200)
    pingActor ! Letters.Stop
    pongActor ! Letters.Stop

Running

The collection of all code can be seen in action in Scastie. The result after running the code is:

Ping received Start
Pong received Ping
Ping received Pong
Pong received Ping
Unhandled letter: Pong, from PongActor@202997d9, while: WaitForStop
Ping received Stop
Pong received Stop

Here we see the same letter unhandled as in the example with Akka, only the programmer has to be explicit about it.

Conclusion

In this example we treated then conversion of a short program from Akka untyped to Leucine with WideActor. The next step would be to eliminate the WideActor in favor of the SelectActor or RestrictActor. That would bring the required robustness of typed Actors back. Just for reference that code is shown below. This can be seen in action in Scastie as well.

import s2a.leucine.actors.*

given ActorContext = ActorContext.system

class PingActor extends RestrictActor(PingActor) :
  import PingActor.State.{WaitForStart,WaitForPong,WaitForStop}
  def receive[Sender <: Accept](letter: Letter[Sender], sender: Sender): Receive = (state: State) => (letter,sender,state) match
    case (PingActor.Start, source: PongActor, WaitForStart) =>
      println("Ping received Start")
      source ! PongActor.Ping
      WaitForPong
    case (PingActor.Pong, source: PongActor, WaitForPong) =>
      println("Ping received Pong")
      source ! PongActor.Ping
      WaitForStop
    case (PingActor.Stop, _, WaitForStop) =>
      println("Ping received Stop")
      stop(Actor.Stop.Direct)
      WaitForStart
    case _ =>
      println(s"Unhandled letter: $letter, from $sender, while: $state")
      state

object PingActor extends RestrictDefine :
  type Accept = PongActor | Anonymous
  sealed trait Letter[Sender <: Accept] extends Actor.Letter[Sender]
  case object Pong  extends Letter[PongActor]
  case object Start extends Letter[PongActor]
  case object Stop  extends Letter[Anonymous]
  enum State extends Actor.State { case WaitForStart, WaitForPong, WaitForStop }
  def initial = State.WaitForStart


class PongActor extends RestrictActor(PongActor) :
  def receive[Sender <: Accept](letter: Letter[Sender], sender: Sender): Receive = (letter,sender) match
    case (PongActor.Ping, source: PingActor) =>
      println("Pong received Ping")
      source ! PingActor.Pong
    case (PongActor.Stop,_) =>
      println("Pong received Stop")
      stop(Actor.Stop.Direct)
    case (_,_) => unmatched(letter,sender)

object PongActor extends RestrictDefine, Stateless :
  type Accept = PingActor | Anonymous
  sealed trait Letter[Sender <: Accept] extends Actor.Letter[Sender]
  case object Ping extends Letter[PingActor]
  case object Stop extends Letter[Anonymous]


object Main :
  given Actor.Anonymous = Actor.Anonymous

  val pingActor = new PingActor
  val pongActor = new PongActor

  def main(args: Array[String]): Unit =
    pingActor.send(PingActor.Start,pongActor)
    Thread.sleep(200)
    pingActor ! PingActor.Stop
    pongActor ! PongActor.Stop