Rob Rob - 17 days ago 10
C# Question

Entity Framework - how to cache and share read-only objects

We have an application with a fairly complex entity model where high-performance and low-latency are essential but we have no need for horizontal scalability. The application has a number of event sources in addition to a self-hosted ASP.NET Web API 2. We use Entity Framework 6 to map from POCO classes to the database (we use the excellent Reverse POCO Generator to generate our classes).

Whenever an event arrives the application has to make some adjustment to the entity model and persist this delta adjustment to the database via EF. At the same time read or update requests may arrive via the Web API.

Because the model involves many tables and FK relationships, and reacting to an event usually requires all relationships under the subject entity to be loaded, we have elected to maintain the entire set of data in an in-memory cache rather than load the entire object graph for each event. The image below shows a simplified version of our model:-

enter image description here

At program start-up we load all the interesting

ClassA
instances (and their associated dependency graph) via a temporary
DbContext
and insert into a Dictionary (ie our cache). When an event arrives we find the ClassA instance in our cache and attach it to a per-event
DbContext
via
DbSet.Attach()
. The program is written using the await-async pattern throughout and multiple events can be processed at the same time. We protect the cached objects from being accessed concurrently by the use of locks so we guarantee that a cached
ClassA
can be loaded into a
DbContext
only one-at-a-time. So far so good, the performance is excellent and we are happy with the mechanism. But there is a problem. Although the entity graph is fairly self-contained under
ClassA
, there are some POCO classes representing what we consider to be read-only static-data (shaded in orange in the image). We have found that EF sometimes complains


An entity object cannot be referenced by multiple instances of IEntityChangeTracker.


when we attempt to
Attach()
two different instances of
ClassA
at the same time (even though we are attaching to different
Dbcontexts
) because they share a reference to the same
ClassEType
.

Is there any way to inform EF that
ClassEType
is read-only/static and we don't want it to ensure that each instance can be loaded into only one
DbContext
? So far the only way around the problem I've found is to modify the POCO generator to ignore these FK relationships, so they are not part of the entity model. But this complicates the programming because there are processing methods in
ClassE
which need access to the static data.

Rob Rob
Answer

I think the key to this question is what exactly the exception means:-

An entity object cannot be referenced by multiple instances of IEntityChangeTracker.

It occurred to me that perhaps this exception is Entity Framework complaining that an instance of an object has been changed in multiple DbContexts rather than simply being referenced by objects in multiple DbContexts. My theory was based on the fact that the generated POCO classes have reverse FK navigation properties, and that Entity Framework would naturally attempt to fix-up these reverse navigation properties as part of the process of attaching the entity graph to the DbContext (see a description of the fix-up process)

To test out this theory I created a simple test project where I could enable and disable the reverse navigation properties. To my great joy I discovered that the theory was correct, and that EF is quite happy for the objects to be referenced multiple times so long as the objects themselves don't change - and this includes navigation properties being changed by the fix-up process.

So the answer to the question is simply follow 2 rules:

  • Ensure the static data objects are never changed (ideally they should have no public setter properties) and
  • Do not include any FK reverse navigation properties pointing back to the referring classes.

For those interested I've included the test classes below:-

class Program
{
    static void Main(string[] args)
    {
        ConcurrentDictionary<int,ClassA> theCache = null;

        try
        {
            using(var ctx = new MyDbContext())
            {
                var classAs = ctx.ClassAs
                    .Include(a => a.ClassAType)
                    .ToList();

                theCache = new ConcurrentDictionary<int,ClassA>(classAs.ToDictionary(a => a.ID));
            }

            // take 2 instances of ClassA that refer to the same ClassAType
            // and load them into separate DbContexts   
            var classA1 = theCache[1];
            var classA2 = theCache[2];

            var ctx1 = new MyDbContext();
            ctx1.ClassAs.Attach(classA1);

            var ctx2 = new MyDbContext();
            ctx2.ClassAs.Attach(classA2);

            // When ClassAType has a reverse FK navigation property to
            // ClassA we will not reach this line!    

            WriteDetails(classA1);
            WriteDetails(classA2);

            classA1.Name = "Updated";
            classA2.Name = "Updated";

            WriteDetails(classA1);
            WriteDetails(classA2);
        }
        catch(Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        System.Console.WriteLine("End of test");
    }

    static void WriteDetails(ClassA classA)
    {
        Console.WriteLine(String.Format("ID={0} Name={1} TypeName={2}", 
            classA.ID, classA.Name, classA.ClassAType.Name));
    }
}

public class ClassA
{
    public int ID { get; set; }
    public string ClassATypeCode { get; set; }
    public string Name { get; set; }

    //Navigation properties
    public virtual ClassAType ClassAType { get; set; }
}

public class ClassAConfiguration  : System.Data.Entity.ModelConfiguration.EntityTypeConfiguration<ClassA>
    {
    public ClassAConfiguration()
        : this("dbo")
    {
    }

    public ClassAConfiguration(string schema)
    {
        ToTable("TEST_ClassA", schema);
        HasKey(x => x.ID);

        Property(x => x.ID).HasColumnName(@"ID").IsRequired().HasColumnType("int").HasDatabaseGeneratedOption(System.ComponentModel.DataAnnotations.Schema.DatabaseGeneratedOption.Identity);
        Property(x => x.Name).HasColumnName(@"Name").IsRequired().HasColumnType("varchar").HasMaxLength(50);
        Property(x => x.ClassATypeCode).HasColumnName(@"ClassATypeCode").IsRequired().HasColumnType("varchar").HasMaxLength(50);

        //HasRequired(a => a.ClassAType).WithMany(b => b.ClassAs).HasForeignKey(c => c.ClassATypeCode);
        HasRequired(a => a.ClassAType).WithMany().HasForeignKey(b=>b.ClassATypeCode);
    }
}

public class ClassAType
{
    public string Code { get; private set; }
    public string Name { get; private set; }
    public int Flags { get; private set; }


    // Reverse navigation
    //public virtual System.Collections.Generic.ICollection<ClassA> ClassAs { get; set; }
}

public class ClassATypeConfiguration  : System.Data.Entity.ModelConfiguration.EntityTypeConfiguration<ClassAType>
    {
    public ClassATypeConfiguration()
        : this("dbo")
    {
    }

    public ClassATypeConfiguration(string schema)
    {
        ToTable("TEST_ClassAType", schema);
        HasKey(x => x.Code);

        Property(x => x.Code).HasColumnName(@"Code").IsRequired().HasColumnType("varchar").HasMaxLength(12);
        Property(x => x.Name).HasColumnName(@"Name").IsRequired().HasColumnType("varchar").HasMaxLength(50);
        Property(x => x.Flags).HasColumnName(@"Flags").IsRequired().HasColumnType("int");

    }
}

public class MyDbContext : System.Data.Entity.DbContext
{
    public System.Data.Entity.DbSet<ClassA> ClassAs { get; set; }
    public System.Data.Entity.DbSet<ClassAType> ClassATypes { get; set; }

    static MyDbContext()
    {
        System.Data.Entity.Database.SetInitializer<MyDbContext>(null);
    }

    const string connectionString = @"Server=TESTDB; Database=TEST; Integrated Security=True;";

    public MyDbContext()
        : base(connectionString)
    {
    }

    protected override void OnModelCreating(System.Data.Entity.DbModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Configurations.Add(new ClassAConfiguration());
        modelBuilder.Configurations.Add(new ClassATypeConfiguration());
    }
}
Comments