Akka.NET-style actors in Service Fabric

Akka.NET and Service Fabric are the two actor frameworks that emerged in .NET world in the last year. The two implementations of actor models are quite different. These differences are multi-faceted but today I want to focus on API to define an actor and to communicate to it.

Service Fabric Actors

Every actor in Service Fabric has a public interface which describes its behaviour. For this article I’m going to use a toy example based on weather reports. Our actor will be able to get whether reports and then return the maximum temperature for a given period. An instance of actor will be created for each city (geo partitioning). Here is our interface in Service Fabric:

public interface IWeatherActor : IActor
{
    Task AddWeatherReport(WeatherReport report);

    Task<int?> GetMaxTemperature(Period period);
}

We have two operations: a command and a query. They are both async (return Task). The data classes are required to be mutable DTOs based on DataContract:

[DataContract]
public class WeatherReport
{
    [DataMember]
    public DateTime Moment { get; set; }
    [DataMember]
    public int Temperature { get; set; }
    [DataMember]
    public int Humidity { get; set; }
}

[DataContract]
public class Period
{
    [DataMember]
    public DateTime From { get; set; }
    [DataMember]
    public DateTime Until { get; set; }
}

And here is the implementation of the weather actor:

internal class WeatherActor : StatefulActor<List<WeatherReport>>, IWeatherActor
{
    public Task AddWeatherReport(WeatherReport report)
    {
        this.State = this.State ?? new List<WeatherReport>();
        this.State.Add(report);
        return Task.FromResult(0);
    }

    public Task<int?> GetMaxTemperature(Period period)
    {
        return Task.FromResult(
            (this.State ?? Enumerable.Empty<WeatherReport>())
            .Where(r => r.Moment > period.From && r. Moment <= period.Until)
            .Max(r => (int?)r.Temperature));
    }
}

Service Fabric provides reliable storage out of the box, so we are using it to store our reports. There’s no code required to instantiate an actor. Here is the code to use it:

// Submit a new report
IWeatherActor actor = ActorProxy.Create<IWeatherActor>(new ActorId("Amsterdam"));
actor.AddWeatherReport(
    new WeatherReport { Moment = DateTime.Now, Temperature = 22, Humidity = 55 });

// Make a query somewhere else
IWeatherActor actor = ActorProxy.Create<IWeatherActor>(new ActorId("Amsterdam"));
var result = actor.GetMaxTemperature(new Period { From = monthAgo, Until = now });

Akka.NET Actors

Actors in Akka.NET are message-based. The messages are immutable POCOs, which is a great design decision. Here are the messages for our scenario:

public class WeatherReport
{
    public WeatherReport(DateTime moment, int temperature, int humidity)
    {
        this.Moment = moment;
        this.Temperature = temperature;
        this.Humidity = humidity;
    }

    public DateTime Moment { get; }
    public int Temperature { get; }
    public int Humidity { get; }
}

public class Period
{
    public Period(DateTime from, DateTime until)
    {
        this.From = from;
        this.Until = until;
    }

    public DateTime From { get; }
    public DateTime Until { get; }
}

There’s no need to define any interfaces. The basic actor implementation derives from ReceiveActor and calls Receive generic method to setup a callback which is called when a message of specified type is received:

public class WeatherActor : ReceiveActor
{
    private List<WeatherReport> state = new List<WeatherReport>();

    public WeatherActor()
    {
        Receive<WeatherReport>(this.AddWeatherReport);
        Receive<Period>(this.GetMaxTemperature);
    }

    public void AddWeatherReport(WeatherReport report)
    {
        this.state.Add(report);
    }

    public void GetMaxTemperature(Period period)
    {
        var response = this.state
            .Where(r => r.Moment > period.From && r. Moment <= period.Until)
            .Max(r => (int?)r.Temperature);
        Sender.Tell(response, Self);
    }
}

Note a couple more differences in this implementation comparing to Fabric style:

  • State is stored in a normal class field and is not persistent or replicated by default. This can be solved by Akka.NET Persistence, which would save all messages (and potentially snapshots) to the external database. Still, it won’t be the same level of convenience as in-built Service Fabric statefullness.

  • GetMaxTemperature method does not return anything, because nobody would look at the returned value. Instead, it sends yet another message to the sender actor. So, Request-Response workflow is supported but is a bit less convenient and explicit.

Let’s have a look at the client code. ActorSelection is the closest notion to Fabric’s ActorProxy: it does not create an actor, but just gets an endpoint based on the name. Note that Akka.NET actor needs to be explicitly created by another actor, but lifetime management is a separate discussion, so we’ll skip it for now. Here is the report sender:

// Submit a new report
var msg = new WeatherReport { Moment = DateTime.Now, Temperature = 22, Humidity = 55 };
Context.ActorSelection("/user/weather/Amsterdam").Tell(msg);

Asking ActorSelection is not directly possible, we would need to setup an inbox and receive callback messages. We’ll pretend that we have an ActorRef for the sake of simplicity:

// Make a query somewhere else
ActoRef actor = ... ; // we have it
var result = await actor.Ask(new Period { From = monthAgo, Until = now });

The Best of Two Worlds

Now my goals is to come up with an implementation of Service Fabric actors with the properties that combine the good parts of both frameworks (without explicitly using Akka.NET), i.e.

  • Use the full power of Service Fabric actors, including lifetime management, cluster management and reliable state
  • Use the simplicity of Request-Response pattern implementation of Service Fabric
  • Support immutable POCO messages instead of DataContract DTOs
  • Use ReceiveActor-like API for message processing

Here is the third implementation of our Weather Actor (the definitions of messages from Akka.NET example are intact):

[ActorService(Name = "WeatherActor")]
public class WeatherActor : StetefulReceiveActor<List<WeatherReport>>
{
    public WeatherActor()
    {
        Receive<WeatherReport>(this.AddWeatherReport);
        Receive<Period, int>(this.GetMaxTemperature);
    }

    public Task<List<WeatherReport>> AddWeatherReport(
        List<WeatherReport> state, WeatherReport report)
    {
        state = state ?? new List<WeatherReport>();
        state.Add(report);
        return Task.FromResult(state);
    }

    public Task<int?> GetMaxTemperature(List<WeatherReport> state, Period period)
    {
        return Task.FromResult(
            (state ?? Enumerable.Empty<WeatherReport>())
            .Where(r => r.Moment > period.From && r. Moment <= period.Until)
            .Max(r => (int?)r.Temperature));
    }
}

The base ReceiveActor class is not defined yet, we’ll do it in the next section. Here is how it’s being used:

  • The base class is generic and it accepts the type of the state (similar to normal Fabric actors)
  • Constructor registers two Receive handlers: message handler and request handler. Note that the later one accepts two type parameters: request type and response type
  • Both handlers get the current state as the first argument instead of pulling it from the property of the base class
  • The both return Task‘ed data. Message handler is allowed to change the state, while request handler does not change the state but just returns the response back
  • ServiceName attribute is required because there are (may be) multiple classes implementing the same interface

The client code uses our own MessageActorProxy class to create non-generic proxies which are capable to Tell (send a message one way) and Ask (do request and wait for response):

// Submit a new report
var actor = MessageActorProxy.Create(new ActorId("Amsterdam"), "WeatherActor");
actor.Tell(new WeatherReport { Moment = DateTime.Now, Temperature = 22, Humidity = 55 });

// Make a query somewhere else
var actor = MessageActorProxy.Create(new ActorId("Amsterdam"), "WeatherActor");
var result = actor.Ask(new Period { From = monthAgo, Until = now });

Implementation of ReceiveActor

Let’s start with the interface definition:

public interface IReceiveActor : IActor
{
    Task Tell(string typeName, byte[] message);

    [Readonly]
    Task<byte[]> Ask(string typeName, byte[] message);
}

The two methods for Tell and Ask accept serializes data together with fully qualified type name. This will allow passing any kind of objects which can be handled by a serializer of choice (I used Newtonsoft JSON serializer).

Actor implementation derives from StatefulActor and uses another type/bytes pair to store the serialized state:

    public abstract class StatefulReceiveActor : StatefulActor<StateContainer>,
                                                 IReceiveActor
    {
        // ...
    }

    [DataContract]
    public class StateContainer
    {
        [DataMember]
        public string TypeName { get; set; }

        [DataMember]
        public byte[] Data { get; set; }
    }

The simplistic implementation of Receive generic methods uses two dictionaries to store the handlers:

private Dictionary<Type, Func<object, object, Task<object>>> handlers;
private Dictionary<Type, Func<object, object, Task<object>>> askers;

public ReceiveActor()
{
    this.handlers = new Dictionary<Type, Func<object, object, Task<object>>>();
    this.askers = new Dictionary<Type, Func<object, object, Task<object>>>();
}

protected void Receive<T>(Func<object, T, Task<object>> handler)
    => this.handlers.Add(typeof(T), async (s, m) => await handler(s, (T)m));

protected void Receive<TI, TO>(Func<object, TI, Task<TO>> asker)
    => this.askers.Add(typeof(TI), async (s, m) => await asker(s, (TI)m));

The Tell method deserializes the message and state, then picks a handler based on the message type, executes it and serializes the produced state back:

public async Task Tell(string typeName, byte[] message)
{
    var type = Type.GetType(typeName);
    var typedMessage = this.serializer.Deserialize(message, type);

    var typedState = this.State != null
        ? this.serializer.Deserialize(this.State.Data, Type.GetType(this.State.TypeName))
        : null;
    var handler = this.handlers.FirstOrDefault(t => t.Key.IsAssignableFrom(type)).Value;
    if (handler != null)
    {
        var newState = await handler(typedState, typedMessage);
        this.State =
            newState != null
            ? new StateContainer
              {
                  Data = this.serializer.Serialize(newState),
                  TypeName = newState.GetType().AssemblyQualifiedName
              }
            : null;
    }
}

The implementation of Ask is almost identical, so I’ll skip it. MessageActorProxy encapsulates the serialization around passing data to normal ActorProxy class:

public class MessageActorProxy
{
    private readonly IStatefulMessageActor proxy;
    private readonly ISerializer serializer = new JsonByteSerializer();

    private MessageActorProxy(ActorId actorId, string serviceName)
    {
        this.proxy = ActorProxy.Create<IReceiveActor>(actorId, serviceName: serviceName);
    }

    public async Task Tell(object message)
    {
        var serialized = this.serializer.Serialize(message);
        await this.proxy.Send(message.GetType().AssemblyQualifiedName, serialized);
    }

    public async Task<T> Ask<T>(object message)
    {
        var serialized = this.serializer.Serialize(message);
        var fullName = message.GetType().AssemblyQualifiedName;
        var response = await this.proxy.Ask(fullName, serialized);
        return (T)this.serializer.Deserialize(response, typeof(T));
    }

    public static MessageActorProxy Create(ActorId actorId, string serviceType)
    {
        return new MessageActorProxy(actorId, serviceType);
    }
}

Let’s briefly wrap it up.

Conclusion

At this stage Azure Service Fabric lacks support of some actor model best practices like message-based API and immutable POCO classes. At the same time, it provides super powerful setup regarding cluster resource management, state replication, fault tolerance and reliable communication. We can borrow some approaches that are used in Akka.NET framework to improve the developer experience who wants to leverage the power of Service Fabric.


Cloud developer and researcher.
Software engineer at Pulumi. Microsoft Azure MVP.

comments powered by Disqus