Jerusalem is the first qibla of Muslims and the capital of Palestine.

Actor Model - Microsoft Orleans

17.09.2023 | min read
ChatGPT is used in this post to correct the text and make it more fluent.

Object-oriented programming (OOP) is based on four fundamental concepts. These are: encapsulation, abstraction, inheritance, and polymorphism. Of these, encapsulation is the most important. It means that the internal data of an object can't be accessed from outside.

Imagine a product object with a stock property. To decrease the stock, we will need a DecreaseStock method. The stock cannot be manipulated from outside, and will be decreased using the DecreaseStock method, which has business rules. But what happens if multiple threads enter the DecreaseStock method at the same time? No one knows how much stock will be decreased.

The reason for this is the shared memory problem, which is the biggest disadvantage of OOP. Of course, there are many ways and approaches to overcome this. The classic method is the locking mechanism, but since it is a very costly operation, it is necessary to avoid it. The Actor model not only saves us from the locking cost, but also allows us to develop in a distributed environment as if it were not distributed. But how does it do this?

Actor Model

The basic building block of the actor model is the actor. In the actor model, everything is an actor. In OOP, everything is an object. We can think of Product as an actor.

Each actor has its own internal state, similar to properties and fields on an object in OOP. However, actors cannot access or change the states of other actors, so there is no shared memory problem like in OOP.

Actors have behaviors, similar to methods in OOP. But instead of calling methods on objects like in OOP, actors communicate using immutable messages.

Actors have mailboxes. Immutable messages are placed in the actor's mailbox. The actor processes these messages in a first-in-first-out (FIFO) order. This completely solves the concurrency problem.

Actor

In the .NET world, the most commonly used providers for the actor model are Akka.NET and Microsoft Orleans. In this post, we will be working with Microsoft Orleans.

Microsoft Orleans

There are two concepts in Microsoft Orleans: Grain and Silo.

A grain is essentially a virtual actor, so we can also call it an actor.

When a message is received by a grain, it becomes active in memory. Subsequent messages are processed on the active grains in memory. Grains that are not used will be deactivated and deleted from memory after a certain period of time.

Grain Cycle

Silo is defined as the host where grains are hosted.

Cluster

As you can see in the image above, there are two Silos in a cluster. The Silos also contain grains. Silos can be easily added or removed according to the workload. These Silos can also run in different locations. This supports Actor Model as a distributed system.

When a message is received by a grain, the framework determines which Silo it will become active on. As developers, we do not need to care about where the grain is active and we cannot directly access the grain. Because a grain can be active in Silo B, then deactivated and reactivated in Silo A, we send the message to the grain through its reference (proxy). The framework takes care of the rest. This is called location transparency.

Grains can be either stateless or stateful. If a grain is stateless, it can be active multiple times according to the workload and the framework distributes the workload evenly. However, if it is stateful, it is only active once and messages are processed through the mailbox.

What can Grains do?

It can update its own state. For example, when a decrease message is received by the ProductStockGrain with ID 001, it can decrease its stock.

It can communicate with other grains by sending messages to them. For example, when the ProductStockGrain with ID 001 receives a decrease message, it can notify another grain if the stock is insufficient. This allows it to share its workload with other grains, thus enabling distribution.

Create a Grain

It is time to code. First, we will add a class library to the solution and name it as ProductActor.Contracts.

Then, we will install the following libraries from the Nuget package.

    dotnet add package Microsoft.Orleans.CodeGenerator.MSBuild -v 3.6.5
dotnet add package Microsoft.Orleans.Core.Abstractions -v 3.6.5

Each grain's behavior is defined through an interface. For the ProductStockGrain, we define an interface. We implement the IGrainWithIntegerKey interface, which allows us to specify the ProductStockGrain's identity type as an integer. For a string identity, we can use the IGrainWithStringKey, and for a guid identity, we can use the IGrainWithGuidKey.

The next step is to implement the grain. We will add one more class library to the solution and name it as ProductActor.Grains.

Line 5 : The OnActivate method gets triggered when the grain is activated in memory. For example, it is possible to get the stock of the product with http call.

Line 12 : OnDeactivate method gets trigerred when the grain is removed from memory. For example, it is possible to send the stock of the product to an API.

Lines 26 , 35 : We know that we will not encounter concurrency problems in the Increase and Decrease methods, so we can easily increase and decrease our stock. We know that thanks to the mailbox, messages will be processed in order, which allows us to easily perform operations in a distributed environment in memory. This is a great feature.

Create a Silo

Now we can create a Silo. Silo can be either a console app or a web app. Let's go on with a console app.

We add a console app to the solution and name it ProductActor.Host, then install the following libraries from Nuget package. Add the ProductActor.Grains project as a reference.

    dotnet add package Microsoft.Orleans.Core -v 3.6.5
dotnet add package Microsoft.Orleans.OrleansRuntime -v 3.6.5
dotnet add package OrleansDashboard -v 3.6.2

Edit the Program.cs as follows.

Line 7 : We quickly set up a test environment by configuring the Silo to run on the localhost cluster. In live environments, we can use database systems such as SQL Server, DynamoDB, and MongoDB.

Line 8 : Define an IP for Silo. We can set the Silo IP as 11111 and the gateway IP as 30000. We can start as many Silos as we want by taking these IPs as parameters from the console.

Line 11 : We start the Silo by defining a dev cluster for the ClusterId, allowing it to run on the dev cluster. If multiple Silos start, they can communicate with each other on the dev cluster. This feature is very useful in scenarios like blue-green deployment. The ClusterId can change, but the ServiceId remains constant.

Line 14 : The Dashboard allows us to access metrics like CPU, Memory, and Grain Activation for the Silo and monitor its performance in real-time.

Create a Client

In order to send messages to the grains, we need a client. Let's design this client as an API. Let's add an API project to the solution and name it ProductActor.Api.

Install the following libraries from Nuget package. Add the ProductActor.Contracts project as a reference.

    dotnet add package Microsoft.Orleans.Core -v 3.6.5

Edit the Program.cs as follows.

Line 4 : We also need to specify the gateway port, cluster ID, and service ID values defined in the Silo in the API project.

Line 10 : After registering the ClusterClient in the dependency container, we can send messages to the grains using the IClusterClient.

Lines 17 , 25 and 32 : We can obtain the reference, or proxy, of the grain using the ClusterClient.GetGrain method. By using this proxy, we can send messages to the grain by invoking the Get, Increase, and Decrease methods.

The Host and Client projects are ready. You can test them by first running the Host project and then the Api project. You can access the dashboard at http://localhost:8080 and monitor the performance of the Silo.

Dashboard - Silos
Dashboad - Grains

Stateless Worker Grains

Grains are activated only once and eliminate concurrency issues. However, there are also situations that require what Microsoft calls "functional stateless operations." For example, when a decrease message is received by the ProductStockGrain and the stock quantity is insufficient, we may want to notify. In this case, we can design a separate grain for the notification. In this way, we can break down the grains into as small pieces as possible, and this grain can be activated multiple times depending on the workload.

Let's go back to the Contracts project and add a class, named "IInsufficientStockNotification".

As you can see, there is no difference in defining an interface. Let's go back to the Grains project and add a class, named "InsufficientStockNotificationGrain".

Notice that there is a StatelessWorker attribute. We only need to add this. This attribute allows this grain to be activated multiple times. It has no state.

Communication between Grains

We can go back to the ProductStockGrain and add a check for insufficient stock to the Decrease method. If there is insufficient stock, we can send a message to the ProductNotificationGrain.

If you notice, we use the GrainFactory class to send a message from one grain to another.

Persisted Grains

By default, grains do not store their state in a resource, only in InMemory. If the grain is deactivated, the state will be lost. In order to save the state, we need to define a storage provider first. We can use providers like SQL Server and MongoDB, which can be installed from nuget. In our example, we will use MongoDB. You can start by installing the following nuget package in the Host project.

    dotnet add package Orleans.Providers.MongoDB -v 3.8.0

We edit Program.cs.

Line 4 : We create a storage named "MongoDBStore".

We create a serializable class for the state of our Grain.

We go back to the ProductStockGrain class and define the storage.

Line 1 : The StorageProvider attribute is used to give the name of the storage (MongoDBStore) to the ProductStockGrain.

Line 2 : We inherit the ProductStockGrain from GenericGrain instead of Grain. We provide a serializable class as the generic type.

Lines 5 , 10 , 19 and 27 : Now we access the state of the grain via the State property instead of a field.

Lines 14 and 31 : The WriteStateAsync method is used to send the state to the storage after the Increase and Decrease operations. When the grain is activated, its state will be loaded from MongoDBStore.

When the grain is persisted, the database will look like this.

Persisted Grains

Create a Cluster

We created the cluster on localhost to quickly test. We can also save the cluster and Silos to a storage.

We go back to the Host project and add the definition for UseMongoDBClustering.

When we start the Silo, the database will look like this.

Persisted Cluster

I have started two Silos. You can see that both of their statuses are Active. If you stop the Silo, you will see that its status is Died.

We go back to the Client project and install following nuget package.

    dotnet add package Orleans.Providers.MongoDB -v 3.8.0

We also need to add the UseMongoDBClustering definition to the Api project.

Grain Versioning

In this part, I am proposing a scenario.

According this scenario, We add a Remove method to ProductStockGrain and send these changes to production.

In production, there are active ProductStockGrain's in InMemory. What happens when a remove request is sent to these Grains? An error occurs because these active grains do not have the Remove method yet. What actions should we take in this case?

Fortunately, Orleans grains support versioning. However, a few changes are required for this.

Service Worker Grains do not support versioning.

We go back to IProductStock and add the Version attribute. The default version value is 0.

We must increase the version for each method we add to the interface and each change to the method parameters. We go back to the Host project and define "GrainVersioningOptions".

This is the recommended configuration.

We will assume that we have two silos.

Silo1 has ProductStockGrain's V1 version. The ProductStockGrain with ID 1 is active in InMemory.
Silo2 has ProductStockGrain's V2 version. This version of the Grain include a Remove method.

If a remove request is sent to the ProductStockGrain with ID 1, this Grain will be deactivated in Silo1 and activated in Silo2.
When messages are received for all inactive ProductStockGrain's, these grains will be activated in Silo2.

That's all I have to say. To learn more, you can watch the video. The sample project is available on my github.

ahmetkucukoglu/actor-model-product-app

Product App with Actor Model

C#
5
0

Take care.

Share This Post

Leave a comment

Reply to

Cancel reply
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply. Your comment has been sent successfully reCaptcha couldn't be verified
Made with ASP.NET Core