Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to cache entities in memory and attach them correctly - avoid identity resolution issue #24697

Closed
skyflyer opened this issue Apr 20, 2021 · 3 comments

Comments

@skyflyer
Copy link

Ask a question

I am looking for a guidance on how to cache entities and attach them correctly to the DbContext so that they will play along with the queries that load the "same" entities.

I encountered an issue when caching the results of EF core query and reusing those results in an update scenario. The issue was that EF Core complained with an exception:

InvalidOperationException: The instance of entity type 'State' cannot be tracked because another instance with the key value '{ShortName: ACT}' is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.

That exception is due to the fact that the states were loaded by a query similar to person.Include(p => p.States).ToListAsync(), but when updating a list of states assigned to a "person", instances from cache were used.

In order to avoid this issue, one can attach the cached entities to the DbContext before querying a person with related entities, but it is cumbersome.

The reason for this behaviour is clearly explained in the documentation - Identity resolution and queries (emphasis mine):

It is important to understand that EF Core always executes a LINQ query on a DbSet against the database and only returns results based on what is in the database. However, for a tracking query, if the entities returned are already tracked, then the tracked instances are used instead of creating a instances from the data in the database.

Issue reproduction

I've prepared a simple repro of the issue below, where you can see, that if the cached entities are attached after the query has been performed, then the update will fail with the above exception. The workaround is to attach early in the DbContext initialization, but this is impractical, if done within a "repository", because the cached entities are not "centralized".

So, the question is: is there a way, to cache the entities and attach them "whenever" and make it work? Or do they need to be attached before any of the entities are loaded in order to avoid the documented behaviour? In other words, EF Core uses reference equality when comparing tracked objects, but relies on primary key value to decide which entity to track (e.g. when loading from DB, it skips tracking the loaded entity if another entity with the same primary key value is already tracked).

Reproduction:

Reproduction code
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;

namespace efrepro
{
    public class TestDbContext : DbContext
    {
        public DbSet<Person> People { get; set; }
        public DbSet<State> States { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder options)
        {
            options.UseSqlite(@"Data Source=test.db");
            // options.LogTo(Console.WriteLine);
            options.EnableSensitiveDataLogging();
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.Entity<Person>(conf => {
                conf.HasKey(x => x.Id);
            });

            builder.Entity<State>(conf => {
                conf.HasKey(x => x.ShortName);
                conf.Property(x => x.Name).IsRequired();
            });

            builder.Entity<PersonState>(conf => {
                conf.HasKey(x => new { x.StateShortName, x.PersonId});
                conf.HasOne(x => x.Person).WithMany(x => x.AssignedStates).HasForeignKey(x => x.PersonId).IsRequired();
                conf.HasOne(x => x.State).WithMany().HasForeignKey(x => x.StateShortName).IsRequired();
            });
        }
    }

    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }

        public List<PersonState> AssignedStates { get; set ;} = new List<PersonState>();
    }

    public class PersonState
    {
        public int PersonId { get; set; }
        public Person Person { get; set; }
        
        public string StateShortName { get; set; }
        public State State { get; set; }
        
    }

    public class State
    {
        public string ShortName { get; set; }
        public string Name { get; set; }

        public State() {}
        public State(string shortName, string name) { ShortName = shortName; Name = name; }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var statesCache = new List<State>() {
                        new State("VIC", "Victoria"),
                        new State("NSW", "New South Wales"),
                        new State("TAS", "Tasmania"),
                    };

            // setup
            using(var db = new TestDbContext()) {
                db.Database.EnsureDeleted();
                db.Database.EnsureCreated();
                if (db.States.ToList().Count == 0) {
                    db.States.AddRange(new [] {
                        new State("VIC", "Victoria"),
                        new State("NSW", "New South Wales"),
                        new State("TAS", "Tasmania"),
                    });
                }
                db.SaveChanges();

                if (db.People.Count() == 0) {
                    // seed people
                    var person = new Person { Name = "John Doe" };
                    person.AssignedStates = db.States.Select(x => new PersonState { Person = person, State = x }).ToList();
                    db.People.Add(person);
                }
                db.SaveChanges();
            }

            // action
            using (var db = new TestDbContext()) {

                var person = db.People
                    .Include(x => x.AssignedStates)
                        .ThenInclude(x => x.State)
                    .OrderBy(x => x.Id).First();

                // db.AttachRange(statesCache);
                foreach (var cachedState in statesCache) {
                    if (!db.States.Local.Any(x => x.ShortName == cachedState.ShortName)) {
                        db.Attach(cachedState);
                    }
                }

                Console.WriteLine($"Person: {person.Name}");
                Console.WriteLine($"Assigned states: {person.AssignedStates.Count}");
                foreach(var assignedState in person.AssignedStates) {
                    Console.WriteLine($"  {assignedState.State.Name}");
                }

                person.AssignedStates = statesCache.Skip(1).Select(x => new PersonState { Person = person, State = x}).ToList();
                db.SaveChanges();
            }
        }
    }
}

EF Core version info

EF Core version: 5.0.5
Database provider: Microsoft.EntityFrameworkCore.SqlServer)
Target framework: .NET 5.0
Operating system: MacOS

@skyflyer
Copy link
Author

Perhaps an even more understandable reproduction is the following annotated code:

            using (var db = new TestDbContext()) {

// here we attach the cached entities (would love to attach them on a per-need basis, not always)

                db.AttachRange(statesCache);

// here we query an entity which includes related State entity, but since it was attached before, object instances are 
// NOT replaced

                var person = db.People
                    .Include(x => x.AssignedStates)
                        .ThenInclude(x => x.State)
                    .OrderBy(x => x.Id).First();

                Console.WriteLine($"Person: {person.Name}");
                Console.WriteLine($"Assigned states: {person.AssignedStates.Count}");
                foreach(var assignedState in person.AssignedStates) {
                    Console.WriteLine($"  {assignedState.State.Name}");
                }

// here we are intentionally using a new object instance (with the same key ("VIC")), to reproduce the error

                person.AssignedStates = new List<PersonState> { new PersonState { Person = person, State = new State("VIC", "Victoria")}};
                db.SaveChanges();
            }

I would expect, that since the entity State with key VIC is already tracked, that the last statement would succeed, but EF detects that new State("VIC", "Victoria") is a different object instance than the one in the cache and throws InvalidOperationException.

@ajcvickers
Copy link
Member

So, the question is: is there a way, to cache the entities and attach them "whenever" and make it work?

No, this isn't currently possible. Please vote for Provide a hook for identity resolution. The challenge with doing this automatically is how to deal with differences in the two graphs, in particular when it comes to navigations.

@skyflyer
Copy link
Author

Thank you @ajcvickers. I figured as much. So, basically, the only approach to caching is to eagerly attach them to the DbContext.

@ajcvickers ajcvickers reopened this Oct 16, 2022
@ajcvickers ajcvickers closed this as not planned Won't fix, can't repro, duplicate, stale Oct 16, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants