Jul 22, 2018

[Golang] protoactor-go 101: Introduction to golang's actor model implementation

A year has passed since I officially launched go-sarah. While this bot framework had been a great help with my ChatOps, I found myself becoming more and more interested in designing a chat system as a whole. Not just a text-based communication tool or its varied extension; but as a customizable event aggregation system that provides and consumes any conceivable event varied from virtual to real-life. In the course of its server-side design, Golang’s actor model implementation, protoactor-go, seemed like a good option. However, protoactor-go is still in its Beta phase and has less documentation at this point in time. This article describes what I have learned about this product. The basic of actor model is not going to be covered, but for those who are interested, my previous post “Yet another Akka introduction for dummies“ might be a help.

Unless otherwise noted, this introduction is based on the latest version as of 2018-07-21.

Terms, Concepts, and Common Types

Message

With the nature of the actor model, a message plays an important part to let actors interact with others. Messages internally fall into two categories:

  • User message … Messages defined by developers for actor interaction.
  • System message … Messages defined by protoactor-go for internal use that mainly handles the actor lifecycle.

PID

actor.PID is a container that combines a unique identifier, the address and a reference to actor.Process altogether. Since this provides interfaces for others to interact with the underlying actor, this can be seen as an actor reference if one is familiar with Akka. Or simply a Pid if familiar with Erlang. However, this is very important to remember that an actor process is not the only entity that a PID encapsulates.

Process

actor.Process defines a common interface that all interacting “process” must implement. In this project, the concepts of process and PID are quite similar to those of Erlang. Understanding that PID is not necessarily a representation of an actor process is vital when referring to actor messaging context. This distinction and its importance are described in the follow-up article, [Golang] protoactor-go 101: How actors communicate with each other. Its implementation varies depending on each role as below:

Router

router.process receives a message and broadcasts it to all subordinating actors: “routees.”

Local process

actor.localProcess has a reference to a mailbox. On message reception,  this enqueues the message to its mailbox so the actor can receive this for further procedure.

Remote process

On contrary to a local process, this represents an actor that exists in a remote environment. On message reception, this serializes the message and sends it to the destination host.

Guardian process

When a developer passes a “guardian”’s supervisor strategy for actor constructor, a parent actor is created with this supervisor strategy along with the actor itself. This parent “guardian” actor will take care of the child actor’s uncontrollable state. This should be effective when the constructing actor is the “root actor” – an actor without a parent actor – but customized supervision is still required. When multiple actor constructions contain the same settings for guardian supervision, only one guardian actor is created and this becomes the parent of all actors with the same settings.

Future process

actor.futureProcess provides some dedicated features for Future related tasks.

Dead letter process

actor.deadLetterProcess provides features to handle “dead letters.” A dead letter is a message that failed to reach target because, for example, the target actor did not exist or was already stopped. This dead letter process publishes actor.DeadLetterEvent to the event stream, so a developer can detect the dead letter by subscribing to the event via eventstream.Subscribe().

Mailbox

This works as a queue to receive incoming messages, store them temporarily and pass them to its coupled actor when the actor is ready for message execution. The actor is to receive the message one at a time, execute its task and alter its state if necessary. Mailbox implements mailbox.Inbound interface.

  • Default mailbox … mailbox.defaultMailbox not only receives incoming messages as a mailbox.Inbound implementation, but also coordinates the actor invocation schedule with its mailbox.Dispatcher implementation entity. This mailbox also contains mailbox.MessageInvoker implementation as its entity and its methods are called by mailbox.Dispatcher for actor invocation purpose. actor.localContext implements mailbox.MessageInvoker.

Context

This is equivalent to Akka’s ActorCoontext. This contains contextual information and contextual methods for the underlying actor such as below:

  • References to watching actors and methods to watch/unwatch other actors
  • A reference to the actor who sent the currently processing message and a method to access to this
  • Methods to pass a message to another actor
  • etc…

Middleware

Zero or more pre-registered procedures can be executed around actor invocation, which enables an AOP-like approach to modify behavior.

  • Inbound middleware … actor.InboundMiddleware is a middleware that is executed on message reception. A developer may register one or more middleware via Props.WithMiddleware().
  • Outbound middleware … actor.OutboundMiddleware is a middleware that is executed on message sending. A developer may register one or more middleware via Props.WithOutboundMiddleware().

Router

A sub-package, router, provides a series of mechanism that routes a given message to one or more of its routees.

  • Broadcast router … Broadcast given message to all of its routee actors.
  • Round robin router … Send given message to one of its routee actors chosen by round-robin manner
  • Random router … Send given message to a randomly chosen routee actor.

Event Stream

eventstream.EventStream is a mechanism to publish and subscribe given event where the event is an empty interface, interface{}. So the developer can technically publish and subscribe to any desired event. By default an instance of eventstream.EventStream is cached in package local manner and is used to publish and subscribe events such as dead letter messages.

Actor Construction

To construct a new actor and acquire a reference to this, a developer can feed an actor.Props to actor.Spawn or actor.SpawnNamed. The struct called actor.Props is a set of configuration for actor construction. actor.Props can be initialized with helper functions listed below:

  • actor.FromProducer() … Pass a function that returns an actor.Actor implementation. This returns a pointer to actor.Props, which contains a set of configurations for actor construction.
  • actor.FromFunc() … Pass a function that satisfies actor.ActorFunc type, which receives exactly the same arguments as Actor.Recieve(). This is a handy wrapper of actor.FromProducer.
  • actor.FromSpawnFunc() … Pass a function that satisfies actor.SpawnFunc type. on actor construction, this function is called with a series of arguments containing id, actor.Props and parent PID to construct a new actor. When this function is not set, actor.DefaultSpawner is used.
  • actor.FromInstance() … Deprecated.

Additional configuration can be added via its setter methods with “With” prefix. See example code.

Spawner – Construct actor and initiate its lifecycle

A developer feeds a prepared actor.Props to actor.Spawn() or actor.SpawnNamed() depending on the requirement to initialize an actor, its context, and its mailbox. In any construction flow, Props.spawn() is called. To alter this spawning behavior, an alternative function can be set with actor.FromSpawnFunc() or Props.WithSpawnFunc() to override the default behavior. When none is set, actor.DefaultSpawner is used by default. Its behavior is as below:

  • The default spawner creates an instance of actor.localProcess, which is an actor.Process implementation.
  • Add the instance to actor.ProcessRegistry.
    • The registry returns an error if given id is already registered.
  • Create new actor.localContext which is an actor.Context implementation. This stores all contextual data.
  • Mailbox is created for the context. To modify the behavior of mailbox, use Props.WithDispatcher() and Props.WithMailbox().
  • Created mailbox is stored in the actor.localProcess instance.
  • The pointer to the process is set to actor.PID’s field.
  • actor.localContext also has a reference to the actor.PID as “self.”
  • Start mailbox
  • Enqueue mailbox a startedMessage as a system message which is an instance of actor.Started.

When construction is done and the actor lifecycle is successfully started, actor.PID for the new actor is returned.

Child Actor construction

With the introduced actor construction procedure, a developer can create any “root actor,” an actor with no parent. To achieve a hierarchized actor system, use actor.Context’s Spawn() or SpawnNamed() method. Those methods work similarly to actor.Spawn() and actor.SpawnNamed(), but the single and biggest difference is that they create a parent-child relationship between the spawning actor and the newly created actor. They work as below:

  1. Check if Props.guardianStrategy is set
    • If set, it panics. Because the calling actor is going to be the parent and be obligated to be a supervisor, there is no need to set one. This strategy is to create a parent actor for customized supervision as introduced in the first section.
  2. Call Props.spawn()
    • The ID has a form of {parent-id}/{child-id}
    • Own PID is set as a parent for the new actor
  3. Add created actors actor.PID to its children
  4. Start watching the created actor.PID to subscribe its lifecycle event

See example code.

Supervisor Strategy

This is a parent actor’s responsibility to take care of its child actor’s exceptional state. When a child actor can no longer control its state, based on the “let-it-crash” philosophy, child actor notifies such situation to parent actor by panic(). The parent actor receives such notification with recover() and decides how to treat such failing actor. This decision is made by a customizable actor.SupervisorStrategy. When no strategy is explicitly set by a developer, actor.defaultSupervisorStrategy is set on actor construction.

The supervision flow is as follows:

  1. A mailbox passes a message to Actor.Recieve() via target actor context’s localContext.InvokeUserMessage().
  2. In Actor.Receive(), the actor calls panic().
  3. Caller mailbox catches such uncontrollable state with recover().
  4. The mailbox calls localContext.EscalateFailure(), where localContext is that of the failing actor.
    1. In localContext.EscalateFailure(), this tells itself to suspend any incoming message till recovery is done.
    2. Create actor.Failure instance that holds failing reason and other statistical information, where “reason” is the argument passed to panic().
    3. Judges if the failing actor has any parent
      • If none is found, the failing actor is the “root actor” so the actor.Failure is passed to actor.handleRootFactor().
      • If found, this passes actor.Failure to parent’s PID.sendSystemMessage() to notify failing state
        1. The message is enqueued to parent actor’s mailbox
        2. Parent’s mailbox calls its localContext.InvokeSystemMessage.
        3. actor.Failure is passed to localContext.handleFailure
        4. If its actor.Actor entity itself implements actor.SupervisorStrategy, its HandleFailure() is called.
        5. If not, its supervisor entity’s handleFailure() is called.
        6. In HandleFailure(), decide recovery policy and call localContext.(ResumeChildren|RestartChildren|StopChildren|EscalateFailure).

See example code.

Upcoming Interface Change

A huge interface change is expected according to the issue “Design / API Changes upcoming.”

Further Readings

See below articles for more information: