Edit

Share via


Grain persistence

Grains can have multiple named persistent data objects associated with them. These state objects load from storage during grain activation so they're available during requests. Grain persistence uses an extensible plugin model, allowing you to use storage providers for any database. This persistence model is designed for simplicity and isn't intended to cover all data access patterns. Grains can also access databases directly without using the grain persistence model.

Grain persistence diagram

In the preceding diagram, UserGrain has a Profile state and a Cart state, each stored in a separate storage system.

Goals

  1. Support multiple named persistent data objects per grain.
  2. Allow multiple configured storage providers, each potentially having a different configuration and backed by a different storage system.
  3. Enable the community to develop and publish storage providers.
  4. Give storage providers complete control over how they store grain state data in the persistent backing store. Corollary: Orleans doesn't provide a comprehensive ORM storage solution but allows custom storage providers to support specific ORM requirements as needed.

Packages

You can find Orleans grain storage providers on NuGet. Officially maintained packages include:

API

Grains interact with their persistent state using IPersistentState<TState>, where TState is the serializable state type:

public interface IPersistentState<TState> : IStorage<TState>
{
}

public interface IStorage<TState> : IStorage
{
    TState State { get; set; }
}

public interface IStorage
{
    string Etag { get; }

    bool RecordExists { get; }

    Task ClearStateAsync();

    Task WriteStateAsync();

    Task ReadStateAsync();
}
public interface IPersistentState<TState> where TState : new()
{
    TState State { get; set; }

    string Etag { get; }

    Task ClearStateAsync();

    Task WriteStateAsync();

    Task ReadStateAsync();
}

Orleans injects instances of IPersistentState<TState> into the grain as constructor parameters. You can annotate these parameters with a PersistentStateAttribute attribute to identify the name of the state being injected and the name of the storage provider supplying it. The following example demonstrates this by injecting two named states into the UserGrain constructor:

public class UserGrain : Grain, IUserGrain
{
    private readonly IPersistentState<ProfileState> _profile;
    private readonly IPersistentState<CartState> _cart;

    public UserGrain(
        [PersistentState("profile", "profileStore")] IPersistentState<ProfileState> profile,
        [PersistentState("cart", "cartStore")] IPersistentState<CartState> cart)
    {
        _profile = profile;
        _cart = cart;
    }
}

Different grain types can use different configured storage providers, even if both are the same type (e.g., two different Azure Table Storage provider instances connected to different Azure Storage accounts).

Read state

Grain state automatically reads when the grain activates, but grains are responsible for explicitly triggering the write for any changed grain state when necessary.

If a grain wishes to explicitly re-read its latest state from the backing store, it should call the ReadStateAsync method. This reloads the grain state from the persistent store via the storage provider. The previous in-memory copy of the grain state is overwritten and replaced when the Task from ReadStateAsync() completes.

Access the value of the state using the State property. For example, the following method accesses the profile state declared in the code above:

public Task<string> GetNameAsync() => Task.FromResult(_profile.State.Name);

There's no need to call ReadStateAsync() during normal operation; Orleans loads the state automatically during activation. However, you can use ReadStateAsync() to refresh state modified externally.

See the Failure modes section below for details on error-handling mechanisms.

Write state

You can modify the state via the State property. The modified state isn't automatically persisted. Instead, you decide when to persist state by calling the WriteStateAsync method. For example, the following method updates a property on State and persists the updated state:

public async Task SetNameAsync(string name)
{
    _profile.State.Name = name;
    await _profile.WriteStateAsync();
}

Conceptually, the Orleans Runtime takes a deep copy of the grain state data object for its use during any write operations. Under the covers, the runtime might use optimization rules and heuristics to avoid performing some or all of the deep copy in certain circumstances, provided the expected logical isolation semantics are preserved.

See the Failure modes section below for details on error handling mechanisms.

Clear state

The ClearStateAsync method clears the grain's state in storage. Depending on the provider, this operation might optionally delete the grain state entirely.

Get started

Before a grain can use persistence, you must configure a storage provider on the silo.

First, configure storage providers, one for profile state and one for cart state:

using IHost host = new HostBuilder()
    .UseOrleans(siloBuilder =>
    {
        siloBuilder.AddAzureTableGrainStorage(
            name: "profileStore",
            configureOptions: options =>
            {
                // Configure the storage connection key
                options.ConfigureTableServiceClient(
                    "DefaultEndpointsProtocol=https;AccountName=data1;AccountKey=SOMETHING1");
            })
            .AddAzureBlobGrainStorage(
                name: "cartStore",
                configureOptions: options =>
                {
                    // Configure the storage connection key
                    options.ConfigureTableServiceClient(
                        "DefaultEndpointsProtocol=https;AccountName=data2;AccountKey=SOMETHING2");
                });
    })
    .Build();
var host = new HostBuilder()
    .UseOrleans(siloBuilder =>
    {
        siloBuilder.AddAzureTableGrainStorage(
            name: "profileStore",
            configureOptions: options =>
            {
                // Use JSON for serializing the state in storage
                options.UseJson = true;

                // Configure the storage connection key
                options.ConnectionString =
                    "DefaultEndpointsProtocol=https;AccountName=data1;AccountKey=SOMETHING1";
            })
            .AddAzureBlobGrainStorage(
                name: "cartStore",
                configureOptions: options =>
                {
                    // Use JSON for serializing the state in storage
                    options.UseJson = true;

                    // Configure the storage connection key
                    options.ConnectionString =
                        "DefaultEndpointsProtocol=https;AccountName=data2;AccountKey=SOMETHING2";
                });
    })
    .Build();

Important

Microsoft recommends that you use the most secure authentication flow available. If you're connecting to Azure SQL, Managed Identities for Azure resources is the recommended authentication method.

Now that you've configured a storage provider named "profileStore", you can access this provider from a grain.

You can add persistent state to a grain in two primary ways:

  1. By injecting IPersistentState<TState> into the grain's constructor.
  2. By inheriting from Grain<TGrainState>.

The recommended way to add storage to a grain is by injecting IPersistentState<TState> into the grain's constructor with an associated [PersistentState("stateName", "providerName")] attribute. For details on Grain<TState>, see Using Grain<TState> to add storage to a grain below. Using Grain<TState> is still supported but considered a legacy approach.

Declare a class to hold your grain's state:

[Serializable]
public class ProfileState
{
    public string Name { get; set; }

    public Date DateOfBirth { get; set; }
}

Inject IPersistentState<ProfileState> into the grain's constructor:

public class UserGrain : Grain, IUserGrain
{
    private readonly IPersistentState<ProfileState> _profile;

    public UserGrain(
        [PersistentState("profile", "profileStore")]
        IPersistentState<ProfileState> profile)
    {
        _profile = profile;
    }
}

Important

The profile state will not be loaded at the time it is injected into the constructor, so accessing it is invalid at that time. The state will be loaded before OnActivateAsync is called.

Now that the grain has a persistent state, you can add methods to read and write the state:

public class UserGrain : Grain, IUserGrain
{
    private readonly IPersistentState<ProfileState> _profile;

    public UserGrain(
        [PersistentState("profile", "profileStore")]
        IPersistentState<ProfileState> profile)
    {
        _profile = profile;
    }

    public Task<string> GetNameAsync() => Task.FromResult(_profile.State.Name);

    public async Task SetNameAsync(string name)
    {
        _profile.State.Name = name;
        await _profile.WriteStateAsync();
    }
}

Failure modes for persistence operations

Failure modes for read operations

Failures returned by the storage provider during the initial read of state data for a particular grain fail the activation operation for that grain. In such cases, there won't be any call to that grain's OnActivateAsync lifecycle callback method. The original request to the grain that caused the activation faults back to the caller, just like any other failure during grain activation. Failures encountered by the storage provider when reading state data for a particular grain result in an exception from the ReadStateAsync Task. The grain can choose to handle or ignore the Task exception, just like any other Task in Orleans.

Any attempt to send a message to a grain that failed to load at silo startup due to a missing or bad storage provider configuration returns the permanent error BadProviderConfigException.

Failure modes for write operations

Failures encountered by the storage provider when writing state data for a particular grain result in an exception thrown by the WriteStateAsync() Task. Usually, this means the grain call exception is thrown back to the client caller, provided the WriteStateAsync() Task is correctly chained into the final return Task for this grain method. However, in certain advanced scenarios, you can write grain code to specifically handle such write errors, just like handling any other faulted Task.

Grains executing error-handling or recovery code must catch exceptions or faulted WriteStateAsync() Tasks and not rethrow them, signifying they have successfully handled the write error.

Recommendations

Use JSON serialization or another version-tolerant serialization format

Code evolves, and this often includes storage types. To accommodate these changes, configure an appropriate serializer. For most storage providers, a UseJson option or similar is available to use JSON as the serialization format. Ensure that when evolving data contracts, already-stored data can still be loaded.

Using Grain<TState> to add storage to a grain

Important

Using Grain<T> to add storage to a grain is considered legacy functionality. Add grain storage using IPersistentState<T> as previously described.

Grain classes inheriting from Grain<T> (where T is an application-specific state data type needing persistence) have their state loaded automatically from the specified storage.

Mark such grains with a StorageProviderAttribute specifying a named instance of a storage provider to use for reading/writing the state data for this grain.

[StorageProvider(ProviderName="store1")]
public class MyGrain : Grain<MyGrainState>, /*...*/
{
  /*...*/
}

The Grain<T> base class defines the following methods for subclasses to call:

protected virtual Task ReadStateAsync() { /*...*/ }
protected virtual Task WriteStateAsync() { /*...*/ }
protected virtual Task ClearStateAsync() { /*...*/ }

The behavior of these methods corresponds to their counterparts on IPersistentState<TState> defined earlier.

Create a storage provider

There are two parts to the state persistence APIs: the API exposed to the grain via IPersistentState<T> or Grain<T>, and the storage provider API, centered around IGrainStorage—the interface storage providers must implement:

/// <summary>
/// Interface to be implemented for a storage able to read and write Orleans grain state data.
/// </summary>
public interface IGrainStorage
{
    /// <summary>Read data function for this storage instance.</summary>
    /// <param name="stateName">Name of the state for this grain</param>
    /// <param name="grainId">Grain ID</param>
    /// <param name="grainState">State data object to be populated for this grain.</param>
    /// <typeparam name="T">The grain state type.</typeparam>
    /// <returns>Completion promise for the Read operation on the specified grain.</returns>
    Task ReadStateAsync<T>(
        string stateName, GrainId grainId, IGrainState<T> grainState);

    /// <summary>Write data function for this storage instance.</summary>
    /// <param name="stateName">Name of the state for this grain</param>
    /// <param name="grainId">Grain ID</param>
    /// <param name="grainState">State data object to be written for this grain.</param>
    /// <typeparam name="T">The grain state type.</typeparam>
    /// <returns>Completion promise for the Write operation on the specified grain.</returns>
    Task WriteStateAsync<T>(
        string stateName, GrainId grainId, IGrainState<T> grainState);

    /// <summary>Delete / Clear data function for this storage instance.</summary>
    /// <param name="stateName">Name of the state for this grain</param>
    /// <param name="grainId">Grain ID</param>
    /// <param name="grainState">Copy of last-known state data object for this grain.</param>
    /// <typeparam name="T">The grain state type.</typeparam>
    /// <returns>Completion promise for the Delete operation on the specified grain.</returns>
    Task ClearStateAsync<T>(
        string stateName, GrainId grainId, IGrainState<T> grainState);
}
/// <summary>
/// Interface to be implemented for a storage able to read and write Orleans grain state data.
/// </summary>
public interface IGrainStorage
{
    /// <summary>Read data function for this storage instance.</summary>
    /// <param name="grainType">Type of this grain [fully qualified class name]</param>
    /// <param name="grainReference">Grain reference object for this grain.</param>
    /// <param name="grainState">State data object to be populated for this grain.</param>
    /// <returns>Completion promise for the Read operation on the specified grain.</returns>
    Task ReadStateAsync(
        string grainType, GrainReference grainReference, IGrainState grainState);

    /// <summary>Write data function for this storage instance.</summary>
    /// <param name="grainType">Type of this grain [fully qualified class name]</param>
    /// <param name="grainReference">Grain reference object for this grain.</param>
    /// <param name="grainState">State data object to be written for this grain.</param>
    /// <returns>Completion promise for the Write operation on the specified grain.</returns>
    Task WriteStateAsync(
        string grainType, GrainReference grainReference, IGrainState grainState);

    /// <summary>Delete / Clear data function for this storage instance.</summary>
    /// <param name="grainType">Type of this grain [fully qualified class name]</param>
    /// <param name="grainReference">Grain reference object for this grain.</param>
    /// <param name="grainState">Copy of last-known state data object for this grain.</param>
    /// <returns>Completion promise for the Delete operation on the specified grain.</returns>
    Task ClearStateAsync(
        string grainType, GrainReference grainReference, IGrainState grainState);
}

Create a custom storage provider by implementing this interface and registering that implementation. For an example of an existing storage provider implementation, see AzureBlobGrainStorage.

Storage provider semantics

An opaque provider-specific Etag value (string) may be set by a storage provider as part of the grain state metadata populated when the state was read. Some providers may choose to leave this as null if they don't use Etags.

Any attempt to perform a write operation when the storage provider detects an Etag constraint violation should cause the write Task to be faulted with transient error InconsistentStateException and wrapping the underlying storage exception.

public class InconsistentStateException : OrleansException
{
    public InconsistentStateException(
    string message,
    string storedEtag,
    string currentEtag,
    Exception storageException)
        : base(message, storageException)
    {
        StoredEtag = storedEtag;
        CurrentEtag = currentEtag;
    }

    public InconsistentStateException(
        string storedEtag,
        string currentEtag,
        Exception storageException)
        : this(storageException.Message, storedEtag, currentEtag, storageException)
    {
    }

    /// <summary>The Etag value currently held in persistent storage.</summary>
    public string StoredEtag { get; }

    /// <summary>The Etag value currently held in memory, and attempting to be updated.</summary>
    public string CurrentEtag { get; }
}

Any other failure conditions from a storage operation must cause the returned Task to be broken with an exception indicating the underlying storage issue. In many cases, this exception might be thrown back to the caller who triggered the storage operation by calling a method on the grain. It's important to consider whether the caller can deserialize this exception. For example, the client might not have loaded the specific persistence library containing the exception type. For this reason, it's advisable to convert exceptions into exceptions that can propagate back to the caller.

Data mapping

Individual storage providers should decide how best to store grain state – blob (various formats / serialized forms) or column-per-field are obvious choices.

Register a storage provider

The Orleans runtime resolves a storage provider from the service provider (IServiceProvider) when a grain is created. The runtime resolves an instance of IGrainStorage. If the storage provider is named (for example, via the [PersistentState(stateName, storageName)] attribute), then a named instance of IGrainStorage is resolved.

To register a named instance of IGrainStorage, use the AddSingletonNamedService extension method, following the example of the AzureTableGrainStorage provider here.