tabs ↹ over ␣ ␣ ␣ spaces

by Jiří {x2} Činčura

How Entity Framework Core’s query cache works

27 Oct 2020 3 mins Entity Framework Core

Last week, when speaking at .NET Developer Days, I got a question about the query cache in Entity Framework Core – is it shared across DbContexts or is it per instance? With this question I realized I know how the cache work(ed) in Entity Framework 6, but I’m not entirely sure how it’s done in Entity Framework Core. Time to explore! And you can go with me.

Let’s do some basic thinking first. Does it make sense to have query cache across instances? For the same DbContext type and hence same model (IModel) for sure. Could it be useful for different DbContexts? Maybe. Probably not. Although you can have, i.e. when using bounded contexts, DbContexts with overlap, the query would have to use only the overlapping part of the model and the cache would have to be able to work on fine granularity.

I’ll try to figure out the result only searching file names, types, content and reading pieces of code. Here we go.

The query cache should be in some file containing query and cache in its name, right? Luckily there’s a CompiledQueryCache.cs. Nice, there’s a IMemoryCache being used and the description states it is a singleton. And the GetOrAddQuery method already has the key as an input argument. This comes from QueryCompiler class and ICompiledQueryCacheKeyGenerator.GenerateCacheKey is used. The CompiledQueryCacheKeyGenerator is the implementation of that interface and it just returns instance of CompiledQueryCacheKey, which is defined as protected readonly struct CompiledQueryCacheKey : IEquatable<CompiledQueryCacheKey>. Cool. The equality is implemented as follows.

public bool Equals(CompiledQueryCacheKey other)
{
    return ReferenceEquals(_model, other._model)
        && _queryTrackingBehavior == other._queryTrackingBehavior
        && _async == other._async
        && ExpressionEqualityComparer.Instance.Equals(_query, other._query);
}

public override int GetHashCode()
{
    var hash = new HashCode();
    hash.Add(_query, ExpressionEqualityComparer.Instance);
    hash.Add(_model);
    hash.Add(_queryTrackingBehavior);
    hash.Add(_async);
    return hash.ToHashCode();
}

OK, so it’s checking the selected tracking behavior, whether it’s async and finally the model plus query. The ExpressionEqualityComparer and specifically the ExpressionComparer seems to be checking whether the “structure” of the query is the same. Makes sense, the canonical version of the query is very likely done in another place. That leaves us only with ReferenceEquals(_model, other._model).

Clearly this is comparing references, hence the question is whether the model (IModel) is somewhat cached between instances too. Again, probably the file is gonna have model and cache in its name. And there seems to be IModelCacheKeyFactory where the implementation ModelCacheKeyFactory is using just ModelCacheKey. And this class has a nice comment.

///         A key that uniquely identifies the model for a given context. This is used to store and lookup
///         a cached model for a given context. This default implementation uses the context type as they key, thus
///         assuming that all contexts of a given type have the same model.

So, the Type of DbContext is used for equality comparisons.

And here you have it. When everything is put together, we can infer the query cache is using IMemoryCache as an implementation, it’s a singleton (aka shared across everything in Entity Framework Core) and caching key ultimately depends on the model, which is the same across same DbContexts.

Profile Picture Jiří Činčura is an independent developer focusing on data and business layers, language constructs, parallelism and databases. Specifically Entity Framework, asynchronous and parallel programming, cloud and Azure. He's Microsoft Most Valuable Professional and you can read his articles, guides, tips and tricks at www.tabsoverspaces.com.