-
Notifications
You must be signed in to change notification settings - Fork 1
WideActor
Text compliant with Leucine version 0.5.x
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.
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.
The steps we have to take to translate this example to Leucine are:
- Derive all letters from
Actor.Letter[Actor]
- Define companion objects for each actor.
- Define states for the different Receive partial functions.
- Derive all actors from
WideActor
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]
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.
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
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.
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
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.
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