title: Custom Grain Storage

Custom Grain Storage

Writing a Custom Grain Storage

In the tutorial on declarative actor storage, we looked at allowing grains to store their state in an Azure table using one of the built-in storage providers. While Azure is a great place to squirrel away your data, there are many alternatives. In fact, there are so many that there was no way to support them all. Instead, Orleans is designed to let you easily add support for your own form of storage by writing a grain storage.

In this tutorial, we’ll walk through how to write a simple file-based grain storage. A file system is not the best place to store grains states as it is local, there can be issues with file locks and the last update date is not sufficient to prevent inconsistency. But it’s an easy example to help us illustrate the implementation of a Grain Storage.

Getting Started

An Orleans grain storage is a class that implements IGrainStorage which is included in Microsoft.Orleans.Core NuGet package.

We also inherit from ILifecycleParticipant<ISiloLifecycle> which will allow us to subscribe to a particular event in the lifecycle of the silo.

We start by creating a class named FileGrainStorage.

  1. using Orleans;
  2. using System;
  3. using Orleans.Storage;
  4. using Orleans.Runtime;
  5. using System.Threading.Tasks;
  6. namespace GrainStorage
  7. {
  8. public class FileGrainStorage : IGrainStorage, ILifecycleParticipant<ISiloLifecycle>
  9. {
  10. private readonly string _storageName;
  11. private readonly FileGrainStorageOptions _options;
  12. private readonly ClusterOptions _clusterOptions;
  13. private readonly IGrainFactory _grainFactory;
  14. private readonly ITypeResolver _typeResolver;
  15. private JsonSerializerSettings _jsonSettings;
  16. public FileGrainStorage(string storageName, FileGrainStorageOptions options, IOptions<ClusterOptions> clusterOptions, IGrainFactory grainFactory, ITypeResolver typeResolver)
  17. {
  18. _storageName = storageName;
  19. _options = options;
  20. _clusterOptions = clusterOptions.Value;
  21. _grainFactory = grainFactory;
  22. _typeResolver = typeResolver;
  23. }
  24. public Task ClearStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
  25. {
  26. throw new NotImplementedException();
  27. }
  28. public Task ReadStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
  29. {
  30. throw new NotImplementedException();
  31. }
  32. public Task WriteStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
  33. {
  34. throw new NotImplementedException();
  35. }
  36. public void Participate(ISiloLifecycle lifecycle)
  37. {
  38. throw new NotImplementedException();
  39. }
  40. }
  41. }

Prior starting the implementation, we create an option class containing the root directory where the grains states files will be stored under. For that we will create an options file FileGrainStorageOptions:

  1. public class FileGrainStorageOptions
  2. {
  3. public string RootDirectory { get; set; }
  4. }

The create a constructor containing two fields, storageName to specify which grains should write using this storage [StorageProvider(ProviderName = "File")] and directory which would be the directory where the grain states will be saved.

IGrainFactory, ITypeResolver will be used in the next section where we will initilize the storage.

We also take two options as argument, our own FileGrainStorageOptions and the ClusterOptions. Those will be needed for the implementation of the storage functionalities.

We also need JsonSerializerSettings as we are serializing and deserializing in Json format.

Json is an implementation detail, it is up to the developer to decide what serialization/deserialization protocol would fit the application. Another common format is binary format.

Initializing the storage

To initialize the storage, we register an Init function on the ApplicationServices lifecycle.

  1. public void Participate(ISiloLifecycle lifecycle)
  2. {
  3. lifecycle.Subscribe(OptionFormattingUtilities.Name<FileGrainStorage>(_storageName), ServiceLifecycleStage.ApplicationServices, Init);
  4. }

The Init function is used to set the _jsonSettings which will be used to configure the Json serializer. At the same time we create the folder to store the grains states if it does not exist yet.

  1. private Task Init(CancellationToken ct)
  2. {
  3. // Settings could be made configurable from Options.
  4. _jsonSettings = OrleansJsonSerializer.UpdateSerializerSettings(OrleansJsonSerializer.GetDefaultSerializerSettings(_typeResolver, _grainFactory), false, false, null);
  5. var directory = new System.IO.DirectoryInfo(_rootDirectory);
  6. if (!directory.Exists)
  7. directory.Create();
  8. return Task.CompletedTask;
  9. }

We also provide a common function to construct the filename ensuring uniqueness per service, grain Id and grain type.

  1. private string GetKeyString(string grainType, GrainReference grainReference)
  2. {
  3. return $"{_clusterOptions.ServiceId}.{grainReference.ToKeyString()}.{grainType}";
  4. }

Reading State

To read a grain state, we get the filename using the function we previously defined and combine it to the root directory coming from the options.

  1. public async Task ReadStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
  2. {
  3. var fName = GetKeyString(grainType, grainReference);
  4. var path = Path.Combine(_options.RootDirectory, fName);
  5. var fileInfo = new FileInfo(path);
  6. if (!fileInfo.Exists)
  7. {
  8. grainState.State = Activator.CreateInstance(grainState.State.GetType());
  9. return;
  10. }
  11. using (var stream = fileInfo.OpenText())
  12. {
  13. var storedData = await stream.ReadToEndAsync();
  14. grainState.State = JsonConvert.DeserializeObject(storedData, _jsonSettings);
  15. }
  16. grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
  17. }

We use the fileInfo.LastWriteTimeUtc as a ETag which will be used by other functions for inconsistency checks to prevent data loss.

Note that for the deserialization, we use the _jsonSettings which was set on the Init function. This is important to be able to serialize/deserialize properly the state.

Writing State

Writing the state is similar to reading the state.

  1. public async Task WriteStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
  2. {
  3. var storedData = JsonConvert.SerializeObject(grainState.State, _jsonSettings);
  4. var fName = GetKeyString(grainType, grainReference);
  5. var path = Path.Combine(_options.RootDirectory, fName);
  6. var fileInfo = new FileInfo(path);
  7. if (fileInfo.Exists && fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
  8. {
  9. throw new InconsistentStateException($"Version conflict (WriteState): ServiceId={_clusterOptions.ServiceId} ProviderName={_storageName} GrainType={grainType} GrainReference={grainReference.ToKeyString()}.");
  10. }
  11. using (var stream = new StreamWriter(fileInfo.Open(FileMode.Create, FileAccess.Write)))
  12. {
  13. await stream.WriteAsync(storedData);
  14. }
  15. fileInfo.Refresh();
  16. grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
  17. }

Similarly as reading, we use _jsonSettings to write the state. The current ETag is used to check against the last updated time in UTC of the file. If the date is different, it means that another activation of the same grain changed the state concurrently. In this situation, we throw an InconsistentStateException which will result in the current activation being killed to prevent overwritting the state previously saved by the other activated grain.

Clearing State

Clearing the state would be deleting the file if the file exists.

  1. public Task ClearStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
  2. {
  3. var fName = GetKeyString(grainType, grainReference);
  4. var path = Path.Combine(_options.RootDirectory, fName);
  5. var fileInfo = new FileInfo(path);
  6. if (fileInfo.Exists)
  7. {
  8. if (fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
  9. {
  10. throw new InconsistentStateException($"Version conflict (ClearState): ServiceId={_clusterOptions.ServiceId} ProviderName={_storageName} GrainType={grainType} GrainReference={grainReference.ToKeyString()}.");
  11. }
  12. grainState.ETag = null;
  13. grainState.State = Activator.CreateInstance(grainState.State.GetType());
  14. fileInfo.Delete();
  15. }
  16. return Task.CompletedTask;
  17. }

For the same reason as WriteState, we check for inconsistency before proceeding to delete the file and reset the ETag, we check if the current ETag is the same as the last write time UTC.

Putting it Together

After that we will create a factory which will allow us to scope the options setting to the provider name and at the same time create an instance of the FileGrainStorage to ease the registration to the service collection.

  1. public static class FileGrainStorageFactory
  2. {
  3. internal static IGrainStorage Create(IServiceProvider services, string name)
  4. {
  5. IOptionsSnapshot<FileGrainStorageOptions> optionsSnapshot = services.GetRequiredService<IOptionsSnapshot<FileGrainStorageOptions>>();
  6. return ActivatorUtilities.CreateInstance<FileGrainStorage>(services, name, optionsSnapshot.Get(name), services.GetProviderClusterOptions(name));
  7. }
  8. }

Lastly to register the grain storage, we create an extension on the ISiloHostBuilder which internally register the grain storage as a named service using .AddSingletonNamedService(...), an extension provided by Orleans.Core.

  1. public static class FileSiloBuilderExtensions
  2. {
  3. public static ISiloHostBuilder AddFileGrainStorage(this ISiloHostBuilder builder, string providerName, Action<FileGrainStorageOptions> options)
  4. {
  5. return builder.ConfigureServices(services => services.AddFileGrainStorage(providerName, options));
  6. }
  7. public static IServiceCollection AddFileGrainStorage(this IServiceCollection services, string providerName, Action<FileGrainStorageOptions> options)
  8. {
  9. services.AddOptions<FileGrainStorageOptions>(providerName).Configure(options);
  10. return services
  11. .AddSingletonNamedService(providerName, FileGrainStorageFactory.Create)
  12. .AddSingletonNamedService(providerName, (s, n) => (ILifecycleParticipant<ISiloLifecycle>)s.GetRequiredServiceByName<IGrainStorage>(n));
  13. }
  14. }

Our FileGrainStorage implements two interfaces, IGrainStorage and ILifecycleParticipant<ISiloLifecycle> therefore we need to register two named services for each interfaces:

  1. return services
  2. .AddSingletonNamedService(providerName, FileGrainStorageFactory.Create)
  3. .AddSingletonNamedService(providerName, (s, n) => (ILifecycleParticipant<ISiloLifecycle>)s.GetRequiredServiceByName<IGrainStorage>(n));

This enables us to add the file storage using the extension on the ISiloHostBuilder:

  1. var silo = new SiloHostBuilder()
  2. .UseLocalhostClustering()
  3. .AddFileGrainStorage("File", opts =>
  4. {
  5. opts.RootDirectory = "C:/TestFiles";
  6. })
  7. .Build();

Now we will be able to decorate our grains with the provider [StorageProvider(ProviderName = "File")] and it will store in the grain state in the root directory set in the options.