Microsoft Orleans implements a Virtual Actor Model with what it calls Grains, and as a result, it is important for developers interacting with grains to not consider their lifecycle. That is, a grain shouldn’t be seen as created, activated, deactivated, or deleted. It is expect that the grain will exist when you need it. When modelling application state as a function of aggregate grain state, it is important not to expose grain lifecycle events as part of that aggregate.

The most common example is a platform on which users are represented by one or more grains and may become “online” or “offline”. Developers will need to know if a player is online, but if we tie that to the activation of their grain, it will always be true upon querying that grain.

This article describes a pattern in which such state is maintained in a secondary system to be consumed or communicated outward, hereby described as the Satellite Pattern. The name for this pattern was provided by Ledjon Behluli – if you’re looking for fantastic content related to Orleans, do check out his blog.

Satellite Pattern Overview

From the perspective of a developer programming against a set of grains, their existence is transparent – they always exist and are never activated or deactivated. In the case where a grain migrates or deactivates through timeout, subsequent messages are passed along to a new activation.

Continuing with the example of tracking players in an online game, we’ll model each user account as a grain, IAccountActor, which has some basic functionality dealing with interacting with users at an individual level. As an example, a user will need to sign in and out as well as send and receive messages. This data will be maintained as part of the account grain’s state, persisted by grain storage.

Particularly, when one user wishes to send a message to a friend, Orleans will activate the target account grain to invoke the SendMessage method, which receives the message and stores it as part of the grain’s state. As a result, we cannot rely on the lifecycle of the account grain to answer the question of Online Status.

public interface IAccountActor : IGrainWithIntegerKey
{w
    ValueTask<bool> SignIn(string status);

    ValueTask<bool> SignOut();

    Task SendMessage(int senderAccountId, string message);

    Task<DirectMessage[]> GetMessages();
}

While we may employ an additional property of the grain’s state to support this data, disconnecting it from the lifecycle of the grain, inherent problems start to show. As grain state grows, activating the grain to read a small set of status data may become problematic. Furthermore, requesting status of several account grains would require invoking methods on each individual grain, causing them to be activated and incurring round trip costs for each grain call.

Instead, if we reverse the pattern such that the account grain periodically communicates its status outward to any number of stores, we can optimize our querying of that data for particular use cases. We may submit online status to a SQL table for fast querying across thousands or millions of accounts, or we may communicate online status and aggregated profile details to a secondary grain such that it may be activated and queried in place of the main account grain. These stores become our satellites, containing and propagating our account data outward, across the ecosystem.

Satellite Data / View Model

In an Event Driven or CQRS architecture, changes to internal state are handled and aggregated by a satellite such as an event handler. This is possible in Orleans through a number of mechanisms, including Streams and direct invocation. In our example, the account grain may submit an OnlineStatusSet event to a Stream, allowing satellite grains to ingest and update a persistent store, such as a SQL table, or Redis key-value pair. The store itself becomes the satellite as the queryable interface for consuming parties. This may provide an ideal implementation for use cases that require querying across all accounts, for instance aggregation of online user count, or searching for players ready for matchmaking.

An example of how this might work is provided below. In our account grain, we create a reference to a stream and pass a message along any time the users initiates an action warranting a change to our online status data. This stream will be used implicitly, so the satellite grain may be activated as necessary.

public sealed class EventDrivenAccountActor : Grain<AccountState>, IEventDrivenAccountActor
{
    private IAsyncStream<OnlineStatusSetEvent>? _stream;

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        // Get the stream
        var streamId = StreamId.Create(Constants.StreamNamespace, this.GetGrainId().GetGuidKey());
        _stream = this
            .GetStreamProvider(Constants.StreamProvider)
            .GetStream<OnlineStatusSetEvent>(streamId);

        return base.OnActivateAsync(cancellationToken);
    }

    public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
    {
        if (_stream is not null)
        {
            _stream = null;
        }

        return base.OnDeactivateAsync(reason, cancellationToken);
    }

    public async ValueTask<bool> SignIn(string status)
    {
        await _stream.OnNextAsync(new OnlineStatusSetEvent
        {
            AccountId = this.GetGrainId().GetGuidKey(),
            Status = status
        });

        return true;
    }

    public async ValueTask<bool> SignOut()
    {
        await _stream.OnNextAsync(new OnlineStatusSetEvent
        {
            AccountId = this.GetGrainId().GetGuidKey(),
            Status = null
        });

        return true;
    }
}

For the satellite, we implement an implicit subscription observer and process incoming messages. We can choose to do a few things with the message, including updating a database, setting internal state, or passing the message along to another system. For the purposes of this article, we are going to create a small view model that gets stored in an in-memory database, using Entity Framework.

[ImplicitStreamSubscription(Constants.StreamNamespace)]
public sealed class AccountEventHandlerActor : Grain<OnlineStatus>, IAccountEventHandlerActor, IAsyncObserver<OnlineStatusSetEvent>, IStreamSubscriptionObserver
{
    private readonly ILogger<IAccountEventHandlerActor> _logger;
    private readonly IAccountStatusService _accountStatusService;

    public AccountEventHandlerActor(ILogger<IAccountEventHandlerActor> logger, IAccountStatusService accountStatusService)
    {
        _logger = logger;
        _accountStatusService = accountStatusService;
    }

    public Task OnCompletedAsync()
    {
        return Task.CompletedTask;
    }

    public Task OnErrorAsync(Exception ex)
    {
        _logger.LogError(ex, ex.Message);
        return Task.CompletedTask;
    }

    public async Task OnNextAsync(OnlineStatusSetEvent item, StreamSequenceToken? token = null)
    {
        _logger.LogInformation($"Received online status event for account {item.AccountId}: {item.Status}");
        
        await _accountStatusService.SetStatus(new AccountStatusView
        {
            AccountId = item.AccountId,
            IsOnline = item.Status != null,
            Status = item.Status == null ? "Offline": $"Last seen {DateTime.UtcNow.ToShortTimeString()}, {item.Status}"
        });
    }

    public async Task OnSubscribed(IStreamSubscriptionHandleFactory handleFactory)
    {
        var handle = handleFactory.Create<OnlineStatusSetEvent>();
        await handle.ResumeAsync(this);
    }
}

This provides us with a location to place our event handler logic, and allows us to abstract the storage of any message from the creation of such a message, providing a platform for better testability and encapsulation.

Because we are using an in-memory database, we cannot query it directly from the client. For this article, a Stateless Worker Grain provides a method of querying that data without exposing the underlying infrastructure to the client.

[StatelessWorker(maxLocalWorkers: 1)]
public class AccountStatusReporterGrain : Grain, IAccountStatusReporter
{
    private readonly IAccountStatusService _accountStatusService;

    public AccountStatusReporterGrain(IAccountStatusService accountStatusService)
    {
        _accountStatusService = accountStatusService;
    }

    public async Task<AccountStatusView[]> GetAccountStatuses()
    {
        return (await _accountStatusService.GetStatuses()).ToArray();
    }

    public async Task<int> GetOnlineCount()
    {
        return await _accountStatusService.GetOnlineCount();
    }
}

To interact with this setup, we call the account and reporter grains respectively, not knowing about the secondary event handler grain. Note that we have to add a delay, to allow the stream to process the message.

var eventDrivenAccount = client.GetGrain<IEventDrivenAccountActor>(accountId);
await eventDrivenAccount.SignIn("Playing Solitaire");

Console.WriteLine("Waiting 1s for the event to be processed...");
await Task.Delay(1000);

Console.WriteLine("Querying for online count...");
var reporter = client.GetGrain<IAccountStatusReporter>(0);
var onlineCount = await reporter.GetOnlineCount();
Console.WriteLine($"Online Count: {onlineCount}");

This will work well for scenarios like querying for the status of users on a friend list, or looking for users waiting to join a game, but because Streams may be delayed, it will not contain the most up-to-date information all the time. What if we wanted to store the data internally and reference each account’s status individually? This is where direct calls with satellite grains comes into play.

Satellite Grains

In situations where account state is required to be individualized, fast and up-to-date, the use of a grain as the satellite becomes optimal. The account grain, upon receiving a change as the result of owner activity, calls the satellite grain to inform it as much, passing any relevant information. The direct invocation allows for additional guarantees not discussed here, such as support for distributed ACID transactions and placement guidance promoting localization optimization. Regardless, the interaction is fast and keeps the satellite up-to-date the moment internal state changes.

Furthermore, implementing a satellite grain provides us with an ideal location for business logic related to the state it is representing. In the case of Online Status, we may wish to timeout a user after a certain period of time. The implementation is straight forward – register a Reminder in the satellite grain with our timeout period and update the state.

We start by creating a satellite grain to maintain the online status data. It has two methods: one to set the status, and one to read it.

public sealed class AccountSatelliteActor : Grain<OnlineStatus>, IAccountSatelliteActor
{
    public Task<OnlineStatus> GetStatus()
    {
        return Task.FromResult(State);
    }

    public Task<bool> SetStatus(string? status)
    {
        State = new OnlineStatus
        {
            Status = status ?? "",
            IsOnline = status != null
        };

        return Task.FromResult(true);
    }
}

We are then able to call the satellite directly from the account grain whenever an action occurs.

public sealed class AccountActor : Grain<AccountState>, IAccountActor
{
    /* Other Implementation */

    public async ValueTask<bool> SignIn(string status)
    {
        await GrainFactory
            .GetGrain<IAccountSatelliteActor>(this.GetGrainId().GetIntegerKey())
            .SetStatus(status);

        return true;
    }

    public async ValueTask<bool> SignOut()
    {
        await GrainFactory
            .GetGrain<IAccountSatelliteActor>(this.GetGrainId().GetIntegerKey())
            .SetStatus(null);

        return true;
    }
}

To interact with these grains from the client side, we are able to call them directly, but we’ve inadvertently exposed the SetStatus method, allowing calling developers to cause trouble!

var account = client.GetGrain<IAccountActor>(accountId);
await account.SignIn("Playing Mahjong");

var accountSatellite = client.GetGrain<IAccountSatelliteActor>(accountId);

// oops! we can set the status mistakenly!
await accountSatellite.SetStatus(null); 

var status = await accountSatellite.GetStatus();
Console.WriteLine($"Account Status: {status.ToDisplayString()}");

While this provides a very efficient and simple to use pattern, it does have one nagging problem. An important part of developing is recognizing that developers will often draw the shortest path to solving a problem. We can prevent this to a degree by removing the ability to call SetStatus on the grain interface, and instead by forcing use of a grain extension.

Extra Credit: Using Grain Extensions

When implementing satellite grains, a downside emerges in that the methods used to alter state become exposed to anyone with a grain reference. While Call Filters / Interceptors offer support for denying access to such method calls, ideally external (to the grain ecosystem) developers should not have access to the methods at all. In order to remove the methods from the grain interface, we may implement a Grain Extension, providing a bridge between the account grain and its satellite that only it uses.

In our implementation, the satellite grain is extended to include a method to alter the online status it maintains, removing it from the grain interface. It is registered and subsequently used by the calling account grain, in order to keep the state up-to-date. Developers an still request status for the account through the normal grain interface, but can no longer affect the internal state.

We start by declaring our extension interface and implementation. Note that the implementation includes a constructor that accepts a Func to call. This is how the extension will change the state of our satellite.

public interface IAccountSecureSatelliteActorExtension : IGrainExtension
{
    Task<bool> SetStatus(string? status);
}

public sealed class AccountSecureSatelliteActorExtension : IAccountSecureSatelliteActorExtension
{
    private readonly Func<string?, Task<bool>> _statusHandler;

    public AccountSecureSatelliteActorExtension(Func<string?, Task<bool>> statusHandler)
    {
        _statusHandler = statusHandler;
    }

    public async Task<bool> SetStatus(string? status)
    {
        var result = await _statusHandler(status);
        return result;
    }
}

We then register the extension in the satellite grain. This will allow calling grains to reference the satellite as the extension interface.

private Task<bool> SetStatus(string? status)
{
    State = new OnlineStatus
    {
        Status = status ?? "",
        IsOnline = status != null
    };

    return Task.FromResult(true);
}

public override Task OnActivateAsync(CancellationToken cancellationToken)
{
    // create the extension, passing it our private method
    var ext = new AccountSecureSatelliteActorExtension(SetStatus);

    // register it!
    GrainContext.SetComponent<IAccountSecureSatelliteActorExtension>(ext);

    return base.OnActivateAsync(cancellationToken);
}

We may then call the extension from the account grain.

public async ValueTask<bool> SignIn(string status)
{
    var satelliteGrain = GrainFactory.GetGrain<IAccountSecureSatelliteActor>(this.GetGrainId().GetGuidKey());
    var ext = satelliteGrain.AsReference<IAccountSecureSatelliteActorExtension>();

    var result = await ext.SetStatus(status);

    return result;
}

public async ValueTask<bool> SignOut()
{
    var satelliteGrain = GrainFactory.GetGrain<IAccountSecureSatelliteActor>(this.GetGrainId().GetGuidKey());
    var ext = satelliteGrain.AsReference<IAccountSecureSatelliteActorExtension>();

    var result = await ext.SetStatus(null);

    return result;
}

Our client code is straight forward. Note that we cannot simply call SetStatus on the account’s satellite grain.

var secureAccount = client.GetGrain<ISecureAccountActor>(accountId);
await secureAccount.SignIn("Playing Counter Strike");

var secureSatellite = client.GetGrain<IAccountSecureSatelliteActor>(accountId);
var secureStatus = await secureSatellite.GetStatus();
Console.WriteLine($"Secure Account Status: {secureStatus.ToDisplayString()}");

Grain Extensions are a powerful feature that allow us to both enhance a grain’s feature set and encapsulate it within a secondary interface, preventing accidental changes to state or misuse. The developer must intentionally use the extension, and that is a key decision point in designing systems for developer use.

The End

Hopefully this has provided a sort of jumping off point for how state can flow in an Orleans ecosystem. Ultimately, the Satellite Pattern is about disconnecting the consumption of particular application state, such as the online status of a user, from the lifecycle events of any individual grain, whether it is the account grain or any other. The satellite itself may take many shapes, and you may find that multiple satellite implementations are required to cover different use cases. Doing so also allows better encapsulation of both the data and business logic associated with managing and accessing that data.

To view the complete sample code and provide feedback on the implementation, you may find it on GitHub: https://github.com/jsedlak/orleans-samples/tree/main/Patterns/SatellitePattern