/Orleans.Providers.EntityFramework

An Entity Framework Core implementation of Orleans Grain Storage. More providers to come later.

Primary LanguageC#MIT LicenseMIT

Orleans.Providers.EntityFramework

An Entity Framework Core implementation of Orleans Grain Storage.

There are some nice to have features missing. I didn't needed them particularly but If you have suggestions or want to help out, it would be much appreciated.

AppVeyor NuGet

Usage

Nuget: https://www.nuget.org/packages/Orleans.Providers.EntityFramework/

or

dotnet add package Orleans.Providers.EntityFramework --version 0.15.1

or

Install-Package Orleans.Providers.EntityFramework --version 0.15.1

And configure the storage provider using SiloHostBuilder:

ISiloHostBuilder builder = new SiloHostBuilder();

builder.AddEfGrainStorage<FrogsDbContext>("ef");  

This requires your DbContext to be registered as well

services
    .AddDbContextPool<FatDbContext>(
        (sp, options) => {});

The GrainStorage will resolve and releases contexts per operation so you won't have many context in use. Hence it's better to use the context pool provided in the entity framework package or use your own.

Configuration

By default the provider will search for key properties on your data models that match your grain interfaces, but you can change the default behavior like so:

services.Configure<GrainStorageConventionOptions>(options =>
{
    options.DefaultGrainKeyPropertyName = "Id";
    options.DefaultPersistenceCheckPropertyName = "Id";
    options.DefaultGrainKeyExtPropertyName = "KeyExt";
});

DefaultPersistenceCheckPropertyName is used to check if a model needs to be inserted into the database or updated. The value of said property will be checked against the default value of the type.

The following sample model would work out of the box for a grain that implements IGrainWithGuidCompoundKey and requires no configuration:

public class Box {
  public Guid Id { get; set; }
  public string KeyExt { get; set; }
  public byte[] ETag { get; set; }
}

If you use conventions (as described, configuring GrainStorageConventionOptions) your context should contain DbSets for your models.

public DbSet<Box> Boxes { get; set; }

Querying models using custom expressions

To configure a special model you can do:

public class SpecialBox {
  public long WeirdId { get; set; }
  public string Type { get; set; }
  public long ClusterIndexId { get; set; }
}

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .UseQueryExpression(grainRef =>
            {
                long key = grainRef.GetPrimaryKeyLong(out string keyExt);
                return (box => box.WeirdId == key && box.Type == keyExt);
            })
    )

or

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .UseKey(box => box.WeirdId)
            .UseKeyExt(box => box.Type)
    )

The UseQueryExpression method instructs the sotrage to use the provided expression to query the database.

Loading additional data on read state

You can load additional data while reading the state. Using the SpecialBox model:

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .UseQuery(context => context.SpecialBoxes.AsNoTracking()
                .Include(box => box.Gems)
                .ThenInclude(gems => gems.Map))
    )

Using custom persistence check

When using Guids as primary keys you're most likely to add a cluster index that is auto incremented. That field can be used to check if the state is already inserted into the database or not:

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .ConfigureIsPersisted(box => box.ClusterIndexId > 0)
    )

or

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .CheckPersistenceOn(box => box.ClusterIndexId)
    )

If you use different cluster indices (In case of mssqlserver) than your primary keys you can configure the dafaults to write less configuration code:

services.Configure<GrainStorageConventionOptions>(options =>
{
    options.DefaultPersistenceCheckPropertyName = "ClusterIndexId";
});

ETags

By default models are searched for Etags and if a property on a model is marked as ConcurrencyToken the storage will pick that up.

Using the fluent API that would be:

builder.Entity<SpecialBox>()
    .Property(e => e.ETag)
    .IsConcurrencyToken();

Models can be further configured using extensions:

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .UseETag()
    )

Using UseETag overload with no params instructs the storage to find an ETag property. If no valid property was found, an exception would be thrown.

Use the following overload to explicitly configure the storage to use the provided property. If the property is not marked as ConcurrencyCheck an exception would be thrown.

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .UseETag(box => box.ETag)
    )

Controlling how the state is saved

When calling writeState, the state object is attached to a context and its state (EF entry state) would be set to Added or Modified.

There are two ways to change the behavior:

GrainStorageContext

GrainStorageContext<Box>.ConfigureEntryState(
    entry => entry.Property(e => e.Title).IsModified = true);

This way only the Title field would be updated.

Things to consider:

  • When configuring the entry manually, the storage provider only attaches the state to the context and doesn't set the entry state. So for example if you call this GrainStorageContext<Box>.ConfigureEntryState(entry => {}); the write operation does nothing.
  • Because GrainStorageContext uses async locals you have to call GrainStorageContext<Box>.Clear() if you want to do multiple writes on the same asynchronous operation.

IGrainStateEntryConfigurator

By implementing IGrainStateEntryConfigurator<TContext, TGrain, TEntity> and registering it.

The default implementation is DefaultGrainStateEntryConfigurator and it just does the following:

public void ConfigureSaveEntry(ConfigureSaveEntryContext<TContext, TEntity> context)
{
	EntityEntry<TEntity> entry = context.DbContext.Entry(context.Entity);

	entry.State = context.IsPersisted
		? EntityState.Modified
		: EntityState.Added;
}

Precompiled Queries

By default all queries are precompiled, unless using ConfigureReadState extension.

You can disable precompilation using

services
    .ConfigureGrainStorageOptions<FatDbContext, SpecialBoxGrain, SpecialBox>(
        options => options
            .PreCompileReadQuery(false)
    )

Conventions

You can change the conventions by implementing IGrainStorageConvention or inheriting from GrainStorageConvention which is used for all types and IGrainStorageConvention<TContext, TGrain, TEntity> for a specific grain type which has no default implementation.

Custom Grain State Setter/Getter

You can implement IEntityTypeResolver or inheriting from EntityTypeResolver so you can have different grain state and storage model. This is particularly useful if you have abstract states or models without public default constructors which is a constraint on orleans grain states.

For example you can have the following class

class GenericGrainState<TEntity> 
{
    public TEntity Value { get; set;}
}

Using a custom EntityTypeResolver you can tell the storage TEntity is the persistent model.

Compatibility

To build for specific dependency versions use:

dotnet test /p:ORLEANS_VERSION=3.0.0 /p:EF_VERSION=3.0.0 /p:MSEXT_VERSION=3.0.0

You can run tests for a specific version using build parameters:

dotnet test /p:ORLEANS_VERSION=3.0.0 /p:EF_VERSION=3.0.0 /p:MSEXT_VERSION=3.0.0

Known Issues and Limitations

  • As types has to be configured in dbcontext, arbitrary types can't use this provider. This specially causes issues with Orleans VersionStoreGrain internal grain, hence this GrainStorage can't be used as default grain storage. I'll handle that special case if I get the time needed.