feat: Cache LINQ query plans per filter shape (fixes #5013) - #5018
feat: Cache LINQ query plans per filter shape (fixes #5013)#5018erdtsieck wants to merge 4 commits into
Conversation
Adds an opt-in query plan cache that reuses compiled LINQ plans across ad-hoc queries sharing the same structural shape (e.g. conditional Where chains behind optional filters) but differing only in captured filter values. - ExpressionShapeVisitor computes a stable SHA-256 shape key by walking a narrow, allow-listed subset of the expression tree (Where/OrderBy/ThenBy/Skip/Take/Select, simple comparisons/member access), while recording each closure-captured/constant sub- expression as a positional value slot. Anything outside that allow-list (Include, Stats, custom SQL, StartsWith, GroupBy, etc.) is flagged unsupported and never cached. - LinqPlanRecorder builds a template pass on a cache miss: it replaces every slot with a unique sentinel value (reusing the existing internal UniqueValueSource/QueryCompiler.Finders machinery from ICompiledQuery support), compiles that template through the normal LinqQueryParser pipeline exactly once, and matches the resulting NpgsqlBatch's parameters back to slots purely by sentinel value identity. If any parameter can't be attributed to a slot (e.g. a multi-tenant tenant_id), the shape is never cached. - On a cache hit, the cached NpgsqlBatch template is cloned with fresh parameter values (NpgsqlParameter.Value is mutable, unlike the Weasel CommandParameter feeding the original SQL fragment), so a hit skips LINQ parsing and SQL generation entirely. - QueryPlanCache is a bounded, FIFO-approximate-LRU, thread-safe cache configured via StoreOptions.Linq.QueryPlanCache = QueryPlanCache.PerShape(maxEntries) or StoreOptions.Linq.EnableQueryPlanCaching(maxEntries). Disabled by default. - Per-query opt-in via query.ToListAsync(token, QueryPlanCaching.Cached) (new overloads on MartenLinqQueryable<T> and QueryableExtensions). Naming note: the issue's example API used QueryPlan.Cached, but Marten.Linq.QueryPlan already exists as the Postgres EXPLAIN plan model, so this uses a distinct QueryPlanCaching enum instead to avoid confusion between the two unrelated concepts. MVP scope: only the ToListAsync<TResult>(token, QueryPlanCaching) terminal operator is wired up to the cache. Other terminals (CountAsync, AnyAsync, streaming, etc.) are left uncached for now; the underlying ExpressionShapeVisitor/QueryPlanCache/LinqPlanRecorder infrastructure is generic enough to extend to them later. Adds src/LinqTests/query_plan_cache_Tests.cs covering: stable shape keys across different values, differing keys across differing shapes and filter combinations, unsupported-shape detection, cached vs. uncached result parity, max-entries bounding, and default opt-out behavior.
Adds a new section to docs/documents/querying/compiled-queries.md covering the query plan cache: when it helps (conditional Where chains that can't use ICompiledQuery), how to enable it via StoreOptions.Linq.QueryPlanCache / EnableQueryPlanCaching(), the per-query ToListAsync(token, QueryPlanCaching.Cached) opt-in, how shape keys are computed, the bounded/LRU cache sizing, and current scope limitations. Also clarifies the distinction from the unrelated IQueryPlan<T> specification pattern and the Postgres EXPLAIN QueryPlan/ExplainAsync() feature already documented on this page. Verified locally with markdownlint-cli and cspell against docs/**/*.md per the repo's documentation linting conventions -- both clean.
Per docs conventions, use a VitePress <Badge type="tip" text="9.18" /> on the heading instead of inline "New in Marten" prose.
Deferred to the 9.19 milestone — not taking this into 9.18Thanks @erdtsieck — the design here is genuinely thoughtful and I want to take it, just not rushed into the next release. A batch-cloning / parameter-swapping cache is exactly the kind of feature that needs to bake, so parking it for 9.19 to harden correctness and coverage first. What's solid (confirmed by reading)
What 9.19 needs before mergeTest coverage is thin relative to the risk. The current 7 tests cover the happy path but not the dangerous ones. Before this ships it needs:
Correctness items under review (I'm completing an adversarial pass; will follow up with specifics): sentinel-vs-real-value collision escaping the rejection guard, shape-key completeness (every SQL-affecting dimension hashed), literal-vs-slot handling, Milestone: 9.19. Not a rejection — a "let's get the correctness envelope nailed down first." |
jeremydmiller
left a comment
There was a problem hiding this comment.
Thanks for this — the architecture is solid and I checked the two things I worried about most, both of which are safe:
- No hot-path regression when disabled. The existing
ToListAsync<T>(token)path is untouched; the cache is reachable only via the newToListAsync<T>(token, QueryPlanCaching.Cached)overload, andExecuteListWithPlanCacheAsyncearly-outs on!cache.Enabled(the defaultQueryPlanCache.Disabled) before any shape analysis. 👍 - No tenant bleed.
CurrentTenantFilteremitstenant_idas a real parameter carrying the actual tenant value (never a sentinel), so the recorder's "every batch parameter must map to a captured sentinel slot, otherwise refuse to cache" guard (LinqPlanRecorder) correctly rejects conjoined-tenant shapes. Concurrency is fine too —RebindValuesclones into a freshNpgsqlBatchand only reads the template.
Blocker before this can merge: null filter values return wrong (empty) results on a cache hit
Marten generates structurally different SQL depending on whether a comparison value is null. See QueryableMember.CreateComparison (src/Marten/Linq/Members/QueryableMember.cs:143-153):
if (unwrappedValue == null)
{
return op == "=" ? new IsNullFilter(this) : new IsNotNullFilter(this); // "col is null" — NO parameter
}
var def = new CommandParameter(Expression.Constant(unwrappedValue));
return new MemberComparisonFilter(this, def, op); // "col = $1" — a parameterSimpleExpression.CompareTo does the same for the captured-member path. The plan template is always recorded with non-null sentinel values (UniqueValueSource yields GUIDs / negative ints), so a cached plan for .Where(x => x.Name == name) always bakes the col = $1 form. On a later cache hit where name is null, RebindValues binds DBNull.Value, producing col = NULL, which matches nothing in Postgres — the correct result is the IS NULL rows. The !=/null case is symmetric (col != NULL instead of IS NOT NULL).
Repro shape:
// first call caches the plan with a non-null value
await q.Where(x => x.Name == "foo").ToListAsync(t, QueryPlanCaching.Cached);
// cache hit — returns [] instead of the rows where Name is null
await q.Where(x => x.Name == null).ToListAsync(t, QueryPlanCaching.Cached);The shape key can't distinguish these — the null-ness is a runtime value of a captured slot, so both calls hash to the same shape yet need different SQL. Optional/nullable filters are exactly the "conditional filter endpoint" scenario this feature targets, so this is a common, silent, wrong-results path.
Suggested fix (simplest safe option): on a cache hit, if any current slot value is null, bypass the cache and fall through to the uncached path. A regression test with a nullable filter (one non-null call priming the plan, then a null call) would lock it in.
Minor (non-blocking)
- The flagship doc example in
docs/documents/querying/compiled-queries.mdusesstatus.HasValue ? ... : true, aConditional, whichExpressionShapeVisitorflags unsupported — so that example never actually caches. Worth switching the doc to an example that does.
I'm holding this out of the 9.19.0 release for now because of the null-value blocker; the feature is opt-in and not yet merged, so it doesn't gate the release. Happy to re-review as soon as the null path is handled.
|
Status update: holding this until the next release cycle rather than closing it. The mechanism is sound in outline and the code is careful, but we are not willing to take on a permanent performance feature whose central claim — that it is faster — has not been measured. Below is the benchmark plan we would want to see, so this can be picked back up with something concrete. Why this is the blockerThe seven tests here are all correctness and shape-key behaviour: same/different shape keys, unsupported-shape flagging, cached-equals-uncached results, max-entries, opt-in gating. All useful, none of them measure time or allocations. That matters more than usual because of what the cache costs on the way in. Every consulted query pays a full expression-tree walk, Incidentally, SHA-256 looks like the wrong tool for a process-local dictionary key — there is no security boundary here, and a non-cryptographic hash (or the shape string itself) would be materially cheaper. Worth revisiting once the numbers exist. The benchmark plan
Measure both levels. This is the part most likely to decide the feature:
Arms to compare:
The unsupported-shape arm is not a footnote. The flagship example in this PR's own documentation is The last arm matters too. Marten currently has no reference to Also worth capturing: allocations per query on the hit path (the What would justify merging
If the first point does not hold, that is a useful result in itself and we would close this without prejudice — it would also tell us something about how strongly the docs should keep recommending compiled queries. Independent of the benchmarksTwo things need fixing regardless, both from the earlier review:
This branch is also now Thanks for your patience on this one. The deferral is about evidence, not about the quality of the work. |
Summary
Implements #5013: an opt-in query plan cache that reuses compiled LINQ plans across ad-hoc queries sharing the same structural "shape" (e.g. conditional
Where()chains behind optional filters) but differing only in the captured filter values. This lets endpoints with dynamic filter combinations get compiled-query-like performance without giving upIQueryableergonomics.Correctness is unaffected for every existing code path -- the cache is entirely additive and disabled by default.
Design
ExpressionShapeVisitor(src/Marten/Linq/Caching/) walks the expression tree and computes a stable SHA-256 shape key. It only understands a narrow, allow-listed subset --Where,OrderBy/OrderByDescending,ThenBy/ThenByDescending,Skip,Take,Select, plus simple comparisons/member access. Anything else (Include(),Stats(), custom SQL,StartsWith(),GroupBy,SelectMany, etc.) flags the shape as unsupported, and it's never cached -- falls straight back to today's behavior. While walking, each closure-captured/constant sub-expression is recorded as a positional "slot" whose value may vary between calls.LinqPlanRecorderbuilds the cached plan on a miss: it replaces every slot with a distinct sentinel value (reusing the same internalUniqueValueSource/QueryCompiler.Findersmachinery that already backsICompiledQuery), compiles that template exactly once through the normalLinqQueryParserpipeline, and matches the resultingNpgsqlBatch's parameters back to slots purely by sentinel-value identity. If any parameter can't be attributed to a slot (e.g. a multi-tenanttenant_id), the shape is rejected outright and never cached -- this is the key correctness guard against replaying a stale/foreign value.NpgsqlBatchis cloned with fresh parameter values pulled from the new expression's slots (NpgsqlParameter.Valueis mutable, unlike the underlying WeaselCommandParameterthat produced the original SQL). This skips LINQ parsing and SQL generation entirely and jumps straight to execution with the cachedIQueryHandler.QueryPlanCacheis a bounded, thread-safe, FIFO-approximate-LRU cache (ConcurrentDictionary+ insertion-orderConcurrentQueue).Configuration
Per-query opt-in:
Naming deviation from the issue
The issue's example used
QueryPlan.Cached, butMarten.Linq.QueryPlanalready exists as the PostgresEXPLAINplan model -- overloading it with a.Cachedstatic member would be confusing. This PR introduces a distinctQueryPlanCachingenum (Default/Cached) instead.Scope (MVP)
Only the
ToListAsync<TResult>(CancellationToken, QueryPlanCaching)terminal operator is wired up to the cache in this pass, matching the issue's own example.CountAsync,AnyAsync, streaming (ToAsyncEnumerable), etc. are left uncached for now -- the underlyingExpressionShapeVisitor/QueryPlanCache/LinqPlanRecorderinfrastructure is generic enough to extend to those later.Sentinel-value generation currently supports
string,Guid,int,long,float,decimal,DateTime,DateTimeOffset, enums, and arrays of those (reusingQueryCompiler.Finders). Types without a registered finder (e.g.bool) simply never get cached -- a safe MVP limitation, not a correctness risk.Documentation
Added a "Query Plan Cache for Ad Hoc Linq Queries" section to
docs/documents/querying/compiled-queries.mdcovering configuration, shape-key mechanics, and current scope. Verified locally withmarkdownlint-cliandcspellper repo conventions -- both clean.Tests
New
src/LinqTests/query_plan_cache_Tests.cscovers:Wherealone vs.Where+OrderBy) -> different keysStartsWith) are correctly flagged as not cacheablemaxEntriesVerification
dotnet buildonsrc/Marten,src/LinqTests, andsrc/CoreTests-- no errors.LinqTestssuite (single TFM, net10.0) run against Postgres: 1405/1405 passed, including the 7 new tests, with zero regressions vs. baseline.