Base solution for your next web application
Open Closed

AbpCompiledQueryCacheKeyGenerator recompiles every query per tenant even with UseAbpQueryCompiler = false #12692


User avatar
0
ricardo created

Versions: ASP.NET Zero 15.3 (Abp.EntityFrameworkCore 11.2.0), EF Core 10.0.7, .NET 10, SQL Server, Azure App Service (MVC + Web.Host). Multi-tenant, with about 141 tenants active at the same time during our peak.

This is related to my previous question #12425 (AbpQueryCompiler). We kept UseAbpQueryCompiler = false because of the "No language defined!" error described there.

Problem

Since ABP 10, DefaultDbContextResolver.CreateOptions always adds AbpDbContextOptionsExtension. This extension replaces EF Core's ICompiledQueryCacheKeyGenerator with AbpCompiledQueryCacheKeyGenerator, which appends AbpDbContext.GetCompiledQueryCacheKey() to every compiled query cache key:

$"{CurrentTenantId?.ToString() ?? "Null"}:{IsSoftDeleteFilterEnabled}:{IsMayHaveTenantFilterEnabled}:{IsMustHaveTenantFilterEnabled}"

This happens regardless of UseAbpQueryCompiler. With the default (false), the global filters are the classic expressions over DbContext members (!IsSoftDeleteFilterEnabled || !e.IsDeleted, e.TenantId == CurrentTenantId, ...), and EF Core turns those members into SQL parameters. So the compiled query does not depend on the tenant or on the filter state. The result is that every query is compiled and cached once per tenant (and once per filter-state combination), with no benefit.

EF Core's internal query cache is a MemoryCache with SizeLimit = 10240, and each entry has size 10. Each query takes 2 entries (the compiled query and the relational command), so only about 500 query × tenant × filter-state combinations fit. With many tenants active at once, the cache is constantly compacted (LRU) and EF keeps recompiling.

What we measured

  • Same query, same tenant, different parameter value: +0 cache entries. Same query in each new tenant: +2 entries, meaning a new compilation.
  • Recompiling a typical query costs about 75–160 ms of CPU, versus 6–18 ms when it comes from the cache.
  • In production, at our peak (141 tenants within the same 30 minutes), the App Service plan reached 98% CPU while SQL Server stayed at about 10%. The API process used about 1.4 cores at only 3–4 requests/s, roughly 400 ms of CPU per request.
  • EF's compile-time-only warnings (FirstWithoutOrderByAndFilterWarning, MultipleCollectionIncludeWarning) were logged all day long, which confirms continuous recompilation.

Repro

In any multi-tenant app on ABP 10+:

  1. Run the same LINQ query inside two units of work, each with a different SetTenantId(...).
  2. Subscribe to EF's DiagnosticListener for CoreEventId.QueryCompilationStarting, or watch the entry count of EF's internal IMemoryCache.

The query is compiled twice. The code on the dev branch is the same.

Workaround we are applying

// In our DbContext (derived from AbpZeroDbContext)
public override string GetCompiledQueryCacheKey() => UseAbpQueryCompiler() ? base.GetCompiledQueryCacheKey() : string.Empty;

Our tests confirm two things. First, the query now compiles only once across tenants and filter states. Second, with that single shared compiled query, the following stay correct: tenant isolation, host access with the tenant filter disabled, and soft delete both on and off.

Questions

  1. Can you confirm this override is safe when UseAbpQueryCompiler = false, meaning nothing else in ABP relies on the tenant being part of the key?
  2. Could the framework append the filter key only when UseAbpQueryCompiler is enabled? Every multi-tenant app on ABP 10+ with default settings is affected, and the impact grows with the number of tenants.
  3. Even with UseAbpQueryCompiler = true: as far as we can tell, in ConfigureMayHaveTenantDbFunction / ConfigureMustHaveTenantDbFunction the current tenant id arrives as args[1], which EF Core turns into a SQL parameter. Only the enabled/disabled state of each filter is inlined into the SQL. Wouldn't a key with just the filter flags be enough? IsMustHaveTenantFilterEnabled already reflects CurrentTenantId != null. That would keep a handful of variants per query instead of one per tenant.

Thanks!

Markdown is supported
Copy & paste or drag & drop images (max 30 MB per image)

No answer yet!